first commit
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
# Optional. Copy to .env - dev.sh sources it as a shell file; use trusted content only.
|
||||||
|
ACTIVE_PUSH_DATA_DIR=./data
|
||||||
|
# Device facing listener (nginx / RustProxy proxies /Microsoft-Server-ActiveSync and autodiscover here)
|
||||||
|
ACTIVE_PUSH_EAS_LISTEN=127.0.0.1:8081
|
||||||
|
# Administration panel (keep on loopback; use SSH tunnel or a reverse proxy with auth)
|
||||||
|
ACTIVE_PUSH_ADMIN_LISTEN=127.0.0.1:9091
|
||||||
|
# ACTIVE_PUSH_ALLOW_REMOTE_ADMIN=true
|
||||||
|
# ACTIVE_PUSH_ADMIN_TLS_CERT=/etc/active-push/tls/fullchain.pem
|
||||||
|
# ACTIVE_PUSH_ADMIN_TLS_KEY=/etc/active-push/tls/privkey.pem
|
||||||
|
# ACTIVE_PUSH_LOG_JSON=true
|
||||||
|
RUST_LOG=active_push=info,warn
|
||||||
|
# Lines shown in the panel log viewer (tracing filter)
|
||||||
|
# ACTIVE_PUSH_PANEL_LOG=active_push=info,warn
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
target/
|
||||||
|
data/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.1 - 2026-11-05
|
||||||
|
|
||||||
|
- Fix: devices migrated from Z-Push looping on unknown collection ids (`collection ... not found`) - such ids are
|
||||||
|
now removed from the device with a FolderHierarchy Delete on the next FolderSync.
|
||||||
|
- Counting: devices are physical phones (DeviceId); mailboxes are counted separately; partnerships shown too.
|
||||||
|
Devices page groups all mailboxes of one phone in one row; Users page renamed to Mailboxes with
|
||||||
|
"same device as" column.
|
||||||
|
- Panel: English by default, spacing between cards, styled select dropdowns, non-overlapping live stream
|
||||||
|
columns, chart axis labels no longer clipped, database size on Overview.
|
||||||
|
- Port 8081: plain text on `/`, plain 404 elsewhere, `/healthz` also checks the database.
|
||||||
|
- Retention: hourly WAL checkpoint/truncate, incremental auto-vacuum, pruning of id maps and very old states,
|
||||||
|
inactive devices removed after 180 days by default, bounded in-memory caches, unknown command names
|
||||||
|
normalised.
|
||||||
|
- `probe`: `--device-id`, `--device-type`, `--model`.
|
||||||
|
- Documentation in English with screenshots (`docs/PANEL.md`), new `LOGS.md` and `TROUBLESHOOTING.md`.
|
||||||
|
|
||||||
|
## 0.1.0 - 2026-11-05
|
||||||
|
|
||||||
|
First release: complete rewrite of Z-Push (combined backend: IMAP/SMTP + CalDAV + CardDAV) in Rust.
|
||||||
|
|
||||||
|
- ActiveSync 2.5-14.1, every command used by iOS, Android and Outlook.
|
||||||
|
- Push (Ping, hanging Sync) with cheap change detection.
|
||||||
|
- Administration panel: overview, live traffic, devices (remote wipe, blocks, approval), mailboxes, backends with
|
||||||
|
connection tests, ActiveSync settings, policies, security, logs and audit.
|
||||||
|
- Login lockouts, access lists, audit, Prometheus.
|
||||||
|
- .deb package, sandboxed systemd unit, Z-Push config importer, `admin/config/device/probe` CLI.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
[package]
|
||||||
|
name = "active-push"
|
||||||
|
version = "0.1.1"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.85"
|
||||||
|
description = "ActivePush - Exchange ActiveSync server (Z-Push rewrite) with built-in administration panel"
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "active-push"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "2"
|
||||||
|
argon2 = "0.5"
|
||||||
|
arc-swap = "1.7"
|
||||||
|
axum = { version = "0.8", default-features = false, features = ["http1", "http2", "json", "query", "tokio", "form"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
bytes = "1"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] }
|
||||||
|
chrono-tz = "0.10"
|
||||||
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
hex = "0.4"
|
||||||
|
hmac = "0.12"
|
||||||
|
http = "1"
|
||||||
|
http-body-util = "0.1"
|
||||||
|
hyper = { version = "1", features = ["server", "http1", "http2"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["server-auto", "tokio", "http1", "http2"] }
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls", "rustls-native-certs", "ring"] }
|
||||||
|
mail-parser = "0.11"
|
||||||
|
md-5 = "0.10"
|
||||||
|
percent-encoding = "2"
|
||||||
|
quick-xml = "0.37"
|
||||||
|
rand = "0.8"
|
||||||
|
regex = "1"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "http2"] }
|
||||||
|
rpassword = "7"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||||
|
rustls-native-certs = "0.8"
|
||||||
|
rustls-pemfile = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "migrate", "macros"] }
|
||||||
|
subtle = "2.6"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "sync", "fs", "io-util", "process"] }
|
||||||
|
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
|
tower = { version = "0.5", features = ["util", "limit"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
|
||||||
|
url = "2.5"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
webpki-roots = "0.26"
|
||||||
|
zeroize = "1.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = "debuginfo"
|
||||||
|
panic = "unwind"
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Copyright 2007 - 2016 Zarafa Deutschland GmbH
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License, version 3,
|
||||||
|
as published by the Free Software Foundation with the following additional
|
||||||
|
term according to sec. 7:
|
||||||
|
|
||||||
|
According to sec. 7 of the GNU Affero General Public License, version 3,
|
||||||
|
the terms of the AGPL are supplemented with the following terms:
|
||||||
|
|
||||||
|
"Zarafa" is a registered trademark of Zarafa B.V.
|
||||||
|
"Z-Push" is a registered trademark of Zarafa Deutschland GmbH
|
||||||
|
The licensing of the Program under the AGPL does not imply a trademark license.
|
||||||
|
Therefore any rights, title and interest in our trademarks remain entirely with us.
|
||||||
|
|
||||||
|
Our trademark policy (see TRADEMARKS) allows you to use our trademarks
|
||||||
|
in connection with Propagation and certain other acts regarding the Program.
|
||||||
|
In any case, if you propagate an unmodified version of the Program you are
|
||||||
|
allowed to use the term "Z-Push" to indicate that you distribute the Program.
|
||||||
|
Furthermore you may use our trademarks where it is necessary to indicate the
|
||||||
|
intended purpose of a product or service provided you use it in accordance
|
||||||
|
with honest business practices. For questions please contact Zarafa at
|
||||||
|
trademark@zarafa.com.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# ActivePush
|
||||||
|
|
||||||
|
**Exchange ActiveSync server written in Rust** - a from-scratch successor of **Z-Push** (PHP) for classic mail
|
||||||
|
stacks: **IMAP + SMTP** (e.g. Dovecot + Postfix), **CalDAV** and **CardDAV** (Radicale, SOGo, Baikal,
|
||||||
|
Nextcloud, DAViCal). One binary, no PHP-FPM, with a built-in administration panel in the RustProxy style:
|
||||||
|
configuration, devices, remote wipe, policies, statistics, charts and live traffic.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```
|
||||||
|
phone / Outlook --HTTPS--> nginx | RustProxy --> ActivePush :8081 --> IMAP / SMTP / CalDAV / CardDAV
|
||||||
|
administrator --(SSH tunnel / VPN)-------------> panel :9091
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Area | What you get |
|
||||||
|
|---|---|
|
||||||
|
| Protocol | ActiveSync 2.5, 12.0, 12.1, 14.0, 14.1; WBXML; plain and base64 query strings; multipart responses |
|
||||||
|
| Commands | Sync (incl. empty/partial and hanging Sync), FolderSync, FolderCreate/Update/Delete, GetHierarchy, Ping (push), Provision, SendMail, SmartReply, SmartForward, GetAttachment, ItemOperations (Fetch, byte ranges, EmptyFolderContents), GetItemEstimate, MoveItems, MeetingResponse, Search (GAL and mailbox), ResolveRecipients, Settings, ValidateCert |
|
||||||
|
| Mail | IMAP folder hierarchy and SPECIAL-USE, read/flag/answered/forwarded, move, trash, attachments, text/HTML/MIME bodies with truncation and preview, S/MIME passthrough, iMIP invitations as MeetingRequest |
|
||||||
|
| Calendar | VEVENT <-> EAS: time zones (TIME_ZONE_INFORMATION <-> IANA), all-day events, RRULE recurrences, exceptions (EXDATE + RECURRENCE-ID), attendees and responses, reminders, busy status, categories |
|
||||||
|
| Tasks | VTODO <-> EAS Tasks (due, start, completion, priority, reminder, recurrence) |
|
||||||
|
| Contacts | vCard 2.1/3.0/4.0 <-> EAS Contacts with **lossless merging** of fields ActiveSync cannot represent |
|
||||||
|
| Push | Ping with cheap change detection (IMAP STATUS / DAV CTag every N seconds, full diff only when a signature changes) |
|
||||||
|
| Security | per-account and per-IP lockout, fail2ban friendly log line, user/domain/network/device-type allow and block lists, device approval, provisioning policies, remote wipe, sender enforcement, DAV SSRF guard, XML without DTD, size limits, panel with argon2id + CSRF + strict CSP |
|
||||||
|
| Panel | overview charts, live traffic (SSE), devices grouped per phone, mailboxes, backends with connection tests, ActiveSync settings, policies, security, logs and audit; English/Polish, dark/light theme |
|
||||||
|
| Operations | hardened systemd unit, portable `.deb`, Prometheus `/metrics`, `/healthz`, Z-Push config importer, CLI equivalent of `z-push-admin`, automatic database retention and compaction |
|
||||||
|
|
||||||
|
## Quick start (development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
./dev.sh backends # terminal 1: Dovecot + Radicale + SMTP sink (needs dovecot-imapd, radicale, aiosmtpd)
|
||||||
|
./dev.sh seed # settings matching those backends
|
||||||
|
./dev.sh # terminal 2: server (ActiveSync :8081, panel :9091)
|
||||||
|
./dev.sh admin setup-token # one-time token for creating the administrator at http://127.0.0.1:9091
|
||||||
|
./dev.sh probe jan@test.local # ActiveSync smoke test (password: Test-Password-123)
|
||||||
|
./dev.sh e2e # end-to-end test of every command
|
||||||
|
```
|
||||||
|
|
||||||
|
`./dev.sh help` lists all commands (`lint`, `test`, `release`, `deb`, `dist`, `import-zpush` ...).
|
||||||
|
|
||||||
|
## Production (Debian/Ubuntu)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh deb # -> dist/active-push_<version>-1_amd64.deb (glibc >= 2.30)
|
||||||
|
sudo apt install ./dist/active-push_*.deb # creates the user and directories, enables the service
|
||||||
|
sudo -u active-push active-push admin setup-token
|
||||||
|
ssh -L 9091:127.0.0.1:9091 server # panel: http://127.0.0.1:9091
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace the Z-Push fastcgi locations in nginx with `proxy_pass` - ready snippet:
|
||||||
|
[`deploy/nginx-active-push.conf`](deploy/nginx-active-push.conf). Details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||||
|
|
||||||
|
## Migrating from Z-Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /usr/share/active-push/zpush-import.py /usr/share/z-push > zpush.json # or /etc/z-push
|
||||||
|
sudo -u active-push active-push config apply zpush.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Devices resynchronise on their own (unknown sync key -> status 9/3, unknown policy key -> provisioning,
|
||||||
|
unknown Z-Push collection ids are removed from the device). See [docs/MIGRATION.md](docs/MIGRATION.md).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [docs/PANEL.md](docs/PANEL.md) - tour of the administration panel with screenshots
|
||||||
|
- [docs/INSTALL.md](docs/INSTALL.md) - installation, nginx/RustProxy, systemd, upgrades, backups
|
||||||
|
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) - every setting and its Z-Push equivalent
|
||||||
|
- [docs/LOGS.md](docs/LOGS.md) - logging, retention, rotation and database maintenance
|
||||||
|
- [docs/SECURITY.md](docs/SECURITY.md) - security model and recommendations
|
||||||
|
- [docs/MIGRATION.md](docs/MIGRATION.md) - moving from Z-Push
|
||||||
|
- [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) - common problems
|
||||||
|
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) - architecture, tests, development environment
|
||||||
|
- [docs/API.md](docs/API.md) - panel API and Prometheus metrics
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL-3.0-only (like Z-Push, from which the WBXML code pages and protocol behaviour originate).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Full release pipeline: lint, tests, optimised binary, Debian package, source archive, checksums.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
fail() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||||
|
need() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required"; }
|
||||||
|
need cargo; need rustc; need python3; need sha256sum; need tar
|
||||||
|
|
||||||
|
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
|
||||||
|
HOST="$(rustc -vV | sed -n 's/^host: //p')"
|
||||||
|
[[ -f Cargo.lock ]] || fail "Cargo.lock is required for a reproducible release"
|
||||||
|
printf 'ActivePush %s release build for %s\n' "$VERSION" "$HOST"
|
||||||
|
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --locked --all-targets -- -D warnings
|
||||||
|
python3 scripts/check-wbxml-names.py
|
||||||
|
if command -v node >/dev/null; then node --check web/app.js; fi
|
||||||
|
cargo test --locked --lib --bins
|
||||||
|
if command -v cargo-zigbuild >/dev/null 2>&1 && [[ "${GLIBC_TARGET:-2.31}" != "0" ]]; then
|
||||||
|
# portable binary (old glibc baseline)
|
||||||
|
cargo zigbuild --locked --release --target "$(uname -m)-unknown-linux-gnu.${GLIBC_TARGET:-2.31}"
|
||||||
|
BIN="target/$(uname -m)-unknown-linux-gnu/release/active-push"
|
||||||
|
else
|
||||||
|
cargo build --locked --release
|
||||||
|
BIN="target/release/active-push"
|
||||||
|
fi
|
||||||
|
[[ -x "$BIN" ]] || fail "release binary missing"
|
||||||
|
"$BIN" --version
|
||||||
|
|
||||||
|
rm -rf dist && mkdir -p dist
|
||||||
|
ART="active-push-${VERSION}-${HOST}"
|
||||||
|
cp "$BIN" "dist/$ART"
|
||||||
|
chmod 0755 "dist/$ART"
|
||||||
|
(cd dist && sha256sum "$ART" > "$ART.sha256")
|
||||||
|
|
||||||
|
if command -v dpkg-deb >/dev/null; then
|
||||||
|
BIN="$BIN" scripts/build-deb.sh --no-build
|
||||||
|
else
|
||||||
|
printf 'dpkg-deb not found: skipping .deb\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
SRC="dist/active-push-${VERSION}-source.tar.gz"
|
||||||
|
tar --exclude=./target --exclude=./data --exclude=./dist --exclude=./.git --exclude=./.env \
|
||||||
|
-czf "$SRC" --transform "s,^\.,active-push-${VERSION}," .
|
||||||
|
(cd dist && sha256sum "$(basename "$SRC")" > "$(basename "$SRC").sha256")
|
||||||
|
|
||||||
|
printf '\nRelease artifacts:\n'
|
||||||
|
ls -1 dist
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
client_max_body_size 64k;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ActivePush Exchange ActiveSync server
|
||||||
|
Documentation=file:///usr/share/doc/active-push/README.md
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target dovecot.service postfix.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=active-push
|
||||||
|
Group=active-push
|
||||||
|
WorkingDirectory=/var/lib/active-push
|
||||||
|
UMask=0077
|
||||||
|
Environment=RUST_LOG=active_push=info,warn
|
||||||
|
Environment=MALLOC_ARENA_MAX=2
|
||||||
|
EnvironmentFile=-/etc/active-push/environment
|
||||||
|
ExecStart=/usr/bin/active-push serve
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
# hanging Ping requests are released on SIGTERM
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=30
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
StateDirectory=active-push
|
||||||
|
StateDirectoryMode=0700
|
||||||
|
ConfigurationDirectory=active-push
|
||||||
|
ConfigurationDirectoryMode=0750
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectProc=invisible
|
||||||
|
ProcSubset=pid
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
# AF_UNIX is needed only for the optional sendmail method
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallFilter=~@privileged @resources
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
ReadWritePaths=/var/lib/active-push
|
||||||
|
# sendmail method: postfix needs its queue directory
|
||||||
|
# ReadWritePaths=/var/spool/postfix/maildrop
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# /etc/active-push/environment - read by the systemd unit (KEY=value, no shell syntax).
|
||||||
|
# All runtime settings (IMAP, SMTP, CalDAV, policies, security…) are managed in the panel.
|
||||||
|
ACTIVE_PUSH_DATA_DIR=/var/lib/active-push
|
||||||
|
ACTIVE_PUSH_EAS_LISTEN=127.0.0.1:8081
|
||||||
|
ACTIVE_PUSH_ADMIN_LISTEN=127.0.0.1:9091
|
||||||
|
# Expose the panel on the network (only with TLS and allowed_networks set in the panel):
|
||||||
|
# ACTIVE_PUSH_ALLOW_REMOTE_ADMIN=true
|
||||||
|
# ACTIVE_PUSH_ADMIN_LISTEN=0.0.0.0:9091
|
||||||
|
# ACTIVE_PUSH_ADMIN_TLS_CERT=/etc/active-push/tls/fullchain.pem
|
||||||
|
# ACTIVE_PUSH_ADMIN_TLS_KEY=/etc/active-push/tls/privkey.pem
|
||||||
|
# ACTIVE_PUSH_LOG_JSON=true
|
||||||
|
RUST_LOG=active_push=info,warn
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# ActivePush - drop-in replacement for the Z-Push PHP-FPM locations.
|
||||||
|
# Put these inside the existing `server { listen 443 ssl; ... }` block of the mail host.
|
||||||
|
|
||||||
|
location = /Microsoft-Server-ActiveSync {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# Ping/hanging Sync keep the request open up to ~59 minutes
|
||||||
|
proxy_read_timeout 3660s;
|
||||||
|
proxy_send_timeout 3660s;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering on;
|
||||||
|
client_max_body_size 64m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check for monitoring / load balancers (no authentication, no data).
|
||||||
|
location = /activesync-healthz {
|
||||||
|
proxy_pass http://127.0.0.1:8081/healthz;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# The same three case variants previously routed to autodiscover.php
|
||||||
|
location = /AutoDiscover/AutoDiscover.xml { include /etc/nginx/snippets/active-push-autodiscover.conf; }
|
||||||
|
location = /Autodiscover/Autodiscover.xml { include /etc/nginx/snippets/active-push-autodiscover.conf; }
|
||||||
|
location = /autodiscover/autodiscover.xml { include /etc/nginx/snippets/active-push-autodiscover.conf; }
|
||||||
|
|
||||||
|
# --- /etc/nginx/snippets/active-push-autodiscover.conf -------------------------
|
||||||
|
# proxy_pass http://127.0.0.1:8081;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# proxy_read_timeout 300s;
|
||||||
|
# client_max_body_size 64k;
|
||||||
|
|
||||||
|
# --- Optional: panel behind nginx with an extra auth layer ----------------------
|
||||||
|
# location /active-push-admin/ {
|
||||||
|
# allow 10.0.0.0/8; deny all;
|
||||||
|
# proxy_pass http://127.0.0.1:9091/;
|
||||||
|
# proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# proxy_buffering off; # live event stream (SSE)
|
||||||
|
# }
|
||||||
|
# The panel uses absolute paths (/api, /app.js); a dedicated (sub)domain is simpler:
|
||||||
|
# server { server_name push-admin.example.com; location / { proxy_pass http://127.0.0.1:9091; ... } }
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
umask 077
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
# Local shell configuration. Do not source untrusted .env files.
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
if ! command -v cargo >/dev/null 2>&1 && [[ -x "$HOME/.cargo/bin/cargo" ]]; then
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
fi
|
||||||
|
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
|
||||||
|
mode="${1:-run}"
|
||||||
|
if [[ $# -gt 0 ]]; then shift; fi
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
help|-h|--help)
|
||||||
|
cat <<HELP
|
||||||
|
ActivePush ${VERSION} - development helper
|
||||||
|
./dev.sh Build and start the server (ActiveSync + panel) with ./data
|
||||||
|
./dev.sh run [flags] Same, extra flags go to 'active-push serve'
|
||||||
|
./dev.sh backends Start throw-away Dovecot + Radicale + SMTP sink (tools/dev)
|
||||||
|
./dev.sh seed Apply tools/dev/test-settings.json (matches 'backends')
|
||||||
|
./dev.sh e2e End-to-end test against a running dev server + backends
|
||||||
|
./dev.sh probe USER ActiveSync smoke test (password prompted)
|
||||||
|
./dev.sh admin setup-token One-time token for creating the first administrator in the panel
|
||||||
|
./dev.sh admin init --username NAME
|
||||||
|
Create the administrator from the shell (hidden password prompt)
|
||||||
|
./dev.sh admin reset-password [--username NAME]
|
||||||
|
./dev.sh admin logout-all | status
|
||||||
|
./dev.sh config get|apply FILE|validate FILE
|
||||||
|
./dev.sh device list|wipe ID|resync ID|remove ID|block ID|unblock ID
|
||||||
|
./dev.sh test Unit tests (cargo test)
|
||||||
|
./dev.sh check cargo check for all targets
|
||||||
|
./dev.sh lint rustfmt --check + clippy -D warnings + JS syntax check
|
||||||
|
./dev.sh fmt Format Rust sources
|
||||||
|
./dev.sh build Debug build only
|
||||||
|
./dev.sh release Optimised build (target/release/active-push)
|
||||||
|
./dev.sh deb Build a Debian package into dist/
|
||||||
|
./dev.sh dist Full release pipeline (build-release.sh)
|
||||||
|
./dev.sh import-zpush DIR Convert a Z-Push installation's config into settings JSON
|
||||||
|
./dev.sh doctor Show available tools
|
||||||
|
|
||||||
|
Defaults (override in .env): ActiveSync 127.0.0.1:8081, panel 127.0.0.1:9091, data ./data
|
||||||
|
HELP
|
||||||
|
exit 0 ;;
|
||||||
|
run|backends|seed|e2e|probe|admin|config|device|test|check|lint|fmt|build|release|deb|dist|import-zpush|doctor) ;;
|
||||||
|
*) printf 'Unknown command: %s. Use ./dev.sh help\n' "$mode" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
doctor)
|
||||||
|
for tool in cargo rustc cc python3 node dpkg-deb dovecot radicale; do
|
||||||
|
printf '%-10s ' "$tool"; command -v "$tool" || printf 'NOT FOUND\n'
|
||||||
|
done
|
||||||
|
exit 0 ;;
|
||||||
|
backends) exec tools/dev/test-backends.sh "$@" ;;
|
||||||
|
import-zpush) exec python3 tools/zpush-import.py "$@" ;;
|
||||||
|
deb) exec scripts/build-deb.sh "$@" ;;
|
||||||
|
dist) exec ./build-release.sh "$@" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
|
cat >&2 <<'MISSING'
|
||||||
|
Rust/Cargo is not installed or not in PATH.
|
||||||
|
Install the stable toolchain from https://rustup.rs and a C linker (build-essential).
|
||||||
|
This script never installs system software or runs sudo.
|
||||||
|
MISSING
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
export ACTIVE_PUSH_DATA_DIR="${ACTIVE_PUSH_DATA_DIR:-$ROOT/data}"
|
||||||
|
export ACTIVE_PUSH_EAS_LISTEN="${ACTIVE_PUSH_EAS_LISTEN:-127.0.0.1:8081}"
|
||||||
|
export ACTIVE_PUSH_ADMIN_LISTEN="${ACTIVE_PUSH_ADMIN_LISTEN:-127.0.0.1:9091}"
|
||||||
|
export RUST_LOG="${RUST_LOG:-active_push=info,warn}"
|
||||||
|
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
|
||||||
|
mkdir -p "$ACTIVE_PUSH_DATA_DIR"
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
fmt) exec cargo fmt --all "$@" ;;
|
||||||
|
check) exec cargo check --all-targets "$@" ;;
|
||||||
|
test) exec cargo test --lib --bins "$@" ;;
|
||||||
|
lint)
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
if command -v node >/dev/null; then node --check web/app.js; fi
|
||||||
|
python3 scripts/check-wbxml-names.py
|
||||||
|
printf 'Lint: OK\n'
|
||||||
|
exit 0 ;;
|
||||||
|
release) exec cargo build --release "$@" ;;
|
||||||
|
e2e)
|
||||||
|
export AP_E2E_URL="${AP_E2E_URL:-http://${ACTIVE_PUSH_EAS_LISTEN}}"
|
||||||
|
exec cargo test --test e2e -- --nocapture --test-threads=1 "$@" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf 'Building ActivePush %s...\n' "$VERSION" >&2
|
||||||
|
cargo build
|
||||||
|
BIN="$CARGO_TARGET_DIR/debug/active-push"
|
||||||
|
case "$mode" in
|
||||||
|
build) printf 'Binary: %s\n' "$BIN" ;;
|
||||||
|
seed) exec "$BIN" config apply tools/dev/test-settings.json ;;
|
||||||
|
probe)
|
||||||
|
user="${1:?usage: ./dev.sh probe USER}"
|
||||||
|
exec "$BIN" probe --url "http://${ACTIVE_PUSH_EAS_LISTEN}/Microsoft-Server-ActiveSync" --user "$user" ;;
|
||||||
|
admin) exec "$BIN" admin "$@" ;;
|
||||||
|
config) exec "$BIN" config "$@" ;;
|
||||||
|
device) exec "$BIN" device "$@" ;;
|
||||||
|
run)
|
||||||
|
printf 'ActiveSync: http://%s/Microsoft-Server-ActiveSync\nPanel: http://%s/\n' "$ACTIVE_PUSH_EAS_LISTEN" "$ACTIVE_PUSH_ADMIN_LISTEN" >&2
|
||||||
|
exec "$BIN" serve "$@" ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Panel API
|
||||||
|
|
||||||
|
Every endpoint except `/api/session`, `/api/login`, `/api/setup`, `/metrics` and `/healthz` requires a session
|
||||||
|
(cookie `ap_session`); methods other than GET additionally need the `X-CSRF-Token` header (value from
|
||||||
|
`/api/session`).
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/session` | session state, CSRF token, whether setup is required |
|
||||||
|
| POST | `/api/setup` | `{token, username, password}` - first administrator |
|
||||||
|
| POST | `/api/login` / `/api/logout` | sign in / out |
|
||||||
|
| POST | `/api/password` | `{current, new}` - change password (revokes other sessions) |
|
||||||
|
| POST | `/api/sessions/revoke-others` | sign out other sessions |
|
||||||
|
| GET | `/api/overview` | counters (`devices_*` = physical devices, `users_*` = mailboxes, `partnerships_total`), commands, runtime, database size |
|
||||||
|
| GET | `/api/timeseries?minutes=60` | minute buckets (up to 24 h) |
|
||||||
|
| GET | `/api/stats/hourly?hours=168` | hourly statistics from the database |
|
||||||
|
| GET | `/api/live` | requests in flight, push connections, recent events |
|
||||||
|
| GET | `/api/events` | Server-Sent Events: `request` event after every request |
|
||||||
|
| GET | `/api/devices`, `/api/devices/{id}` | partnerships (device x mailbox), details (folders, history) |
|
||||||
|
| POST | `/api/devices/{id}/{action}` | `wipe`, `cancel-wipe`, `resync`, `reprovision`, `block`, `unblock`, `approve`, `remove`, `notes`, `recovery-password` |
|
||||||
|
| GET | `/api/users` | mailboxes with device names and other mailboxes on the same device |
|
||||||
|
| GET/PUT | `/api/settings` | settings (secrets as `__SET__`; PUT accepts a full or partial document) |
|
||||||
|
| POST | `/api/test/{imap,smtp,caldav,carddav}` | `{login, password, settings?}` - connection test |
|
||||||
|
| GET | `/api/logs?level=WARN&q=text&n=500` | log buffer |
|
||||||
|
| GET | `/api/audit` | audit log |
|
||||||
|
| GET/POST | `/api/lockouts`, `/api/lockouts/unlock` | login lockouts |
|
||||||
|
|
||||||
|
## Health check
|
||||||
|
|
||||||
|
`GET /healthz` on both listeners returns `ok` (HTTP 200) when the process and the database respond, otherwise
|
||||||
|
HTTP 503.
|
||||||
|
|
||||||
|
## Prometheus
|
||||||
|
|
||||||
|
Set `panel.prometheus_token`, then:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- job_name: active-push
|
||||||
|
metrics_path: /metrics
|
||||||
|
authorization: { type: Bearer, credentials: "<token>" }
|
||||||
|
static_configs: [{ targets: ["127.0.0.1:9091"] }]
|
||||||
|
```
|
||||||
|
|
||||||
|
Metrics: `activepush_requests_total`, `errors_total`, `auth_failures_total`, `bytes_in/out_total`,
|
||||||
|
`items_in/out_total`, `mails_sent_total`, `pings_active`, `imap_pool_connections`, `uptime_seconds`,
|
||||||
|
`resident_memory_bytes`, `command_total{command=...}`.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
Runtime settings live in SQLite (`settings` table, JSON with a revision number) and are edited in the panel.
|
||||||
|
Saving in the panel applies them **immediately** (no restart): the IMAP pool and login cache are cleared and DAV
|
||||||
|
HTTP clients are rebuilt.
|
||||||
|
|
||||||
|
From the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
active-push config get # secrets masked as "__SET__"
|
||||||
|
active-push config get --with-secrets
|
||||||
|
active-push config validate file.json
|
||||||
|
active-push config apply file.json # partial JSON - only the given keys change (deep merge)
|
||||||
|
```
|
||||||
|
|
||||||
|
Startup parameters (listen addresses, data directory, panel TLS) come from `/etc/active-push/environment` or
|
||||||
|
flags (`active-push --help`).
|
||||||
|
|
||||||
|
| Variable | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `ACTIVE_PUSH_DATA_DIR` | `/var/lib/active-push` | database directory |
|
||||||
|
| `ACTIVE_PUSH_EAS_LISTEN` | `127.0.0.1:8081` | ActiveSync + Autodiscover + `/healthz` |
|
||||||
|
| `ACTIVE_PUSH_ADMIN_LISTEN` | `127.0.0.1:9091` | panel |
|
||||||
|
| `ACTIVE_PUSH_ALLOW_REMOTE_ADMIN` | `false` | allow the panel on a non-loopback address |
|
||||||
|
| `ACTIVE_PUSH_ADMIN_TLS_CERT/KEY` | - | panel TLS |
|
||||||
|
| `ACTIVE_PUSH_EAS_TLS_CERT/KEY` | - | ActiveSync TLS (without a reverse proxy) |
|
||||||
|
| `ACTIVE_PUSH_LOG_JSON` | `false` | JSON logs |
|
||||||
|
| `RUST_LOG` | `active_push=info,warn` | log filter |
|
||||||
|
| `ACTIVE_PUSH_PANEL_LOG` | `active_push=info,warn` | filter for the panel log viewer |
|
||||||
|
|
||||||
|
## Z-Push equivalents
|
||||||
|
|
||||||
|
| Z-Push | ActivePush (`section.key`) |
|
||||||
|
|---|---|
|
||||||
|
| `TIMEZONE` | `general.default_timezone` |
|
||||||
|
| `USE_FULLEMAIL_FOR_LOGIN` | `general.login_format` (`as_given` / `local_part` / `full_email`) + `general.default_domain` |
|
||||||
|
| `IMAP_DEFAULTFROM` | `general.email_template` (`{login}`, `{local}@{domain}`, `{local}@example.com`) |
|
||||||
|
| `IMAP_SERVER`, `IMAP_PORT`, `IMAP_OPTIONS` | `imap.host`, `imap.port`, `imap.security` (`none`/`start_tls`/`tls`), `imap.verify_tls` |
|
||||||
|
| `IMAP_FOLDER_*`, `IMAP_FOLDER_PREFIX` | `imap.folder_*`, `imap.folder_prefix`, `imap.use_special_use` |
|
||||||
|
| `IMAP_EXCLUDED_FOLDERS` | `imap.excluded_folders` (list, `prefix*` allowed) |
|
||||||
|
| `IMAP_AUTOSEEN_ON_DELETE`, `IMAP_INLINE_FORWARD` | `imap.autoseen_on_delete`, `imap.inline_forward` |
|
||||||
|
| `IMAP_SMTP_METHOD`, `IMAP_SMTP_PARAMS` | `smtp.method`, `smtp.host`, `smtp.port`, `smtp.security`, `smtp.auth` |
|
||||||
|
| `CALDAV_*` | `caldav.enabled`, `caldav.base_url`, `caldav.path`, `caldav.include_regex`, `caldav.exclude_regex` |
|
||||||
|
| `CARDDAV_*`, `CARDDAV_GAL_PATH`, `CARDDAV_GAL_MIN_LENGTH` | `carddav.*`, `carddav.gal_path`, `carddav.gal_min_length` |
|
||||||
|
| `SYNC_MAX_ITEMS`, `SYNC_FILTERTIME_MAX`, `SYNC_CONFLICT_DEFAULT` | `sync.max_items`, `sync.filter_time_max`, `sync.server_wins` |
|
||||||
|
| `SYNC_CONTACTS_MAXPICTURESIZE` | `carddav.max_picture_kb` |
|
||||||
|
| `PING_INTERVAL`, `PING_*_BOUND_LIFETIME` | `ping.check_interval_secs`, `ping.lifetime_min/max` |
|
||||||
|
| `RETRY_AFTER_DELAY`, `SEARCH_WAIT`, `SEARCH_MAXRESULTS` | `sync.retry_after_secs`, `sync.search_wait_secs`, `sync.search_max_results` |
|
||||||
|
| `PROVISIONING`, `LOOSE_PROVISIONING`, `policies.ini` | `provisioning.enabled`, `provisioning.loose`, `provisioning.policies[]`, `provisioning.user_policies[]` |
|
||||||
|
| `LOGAUTHFAIL`, `LOGUSERLEVEL` | `security.log_auth_failures`, `logging.debug_users` |
|
||||||
|
| `ZPUSH_HOST` (autodiscover) | `autodiscover.server_url` |
|
||||||
|
| `STATE_MACHINE`, `IPC_PROVIDER` | not needed - states in SQLite, IPC in process |
|
||||||
|
|
||||||
|
DAV paths accept `%u` (login), `%l` (local part) and `%d` (domain).
|
||||||
|
|
||||||
|
## Settings without a Z-Push counterpart
|
||||||
|
|
||||||
|
- `security.auth_fail_max / auth_fail_window_secs / lockout_secs` - password guessing protection.
|
||||||
|
- `security.trusted_proxies`, `security.real_ip_header` - real client IP behind a proxy.
|
||||||
|
- `security.eas_allowed_networks`, `panel.allowed_networks` - network allow lists.
|
||||||
|
- `security.allowed_users`, `blocked_users`, `blocked_device_types`, `require_device_approval`.
|
||||||
|
- `security.auth_cache_secs` - successful login cache (HMAC with a random per-process key, never the password).
|
||||||
|
- `smtp.enforce_from` - sender always equals the account address.
|
||||||
|
- `sync.loop_detection` - detects devices replaying an old SyncKey (shrinks the window, reported in the panel).
|
||||||
|
- `imap.pool_*` - pool of authenticated IMAP connections reused between requests.
|
||||||
|
- `panel.prometheus_token`, `panel.device_inactive_days`, `logging.*` - see [LOGS.md](LOGS.md).
|
||||||
|
|
||||||
|
## Time zones
|
||||||
|
|
||||||
|
Devices send the zone as a 172-byte Windows `TIME_ZONE_INFORMATION` structure. ActivePush maps it to an IANA
|
||||||
|
zone, preferring `general.default_timezone` (comparing this year's DST rules). Recurring events are stored in
|
||||||
|
CalDAV with `TZID` and a generated `VTIMEZONE`; single events in UTC (unless the original used `TZID`, in which
|
||||||
|
case the format is kept).
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.rs CLI (serve, admin, config, device, probe)
|
||||||
|
lib.rs public router (ActiveSync, Autodiscover, /, /healthz)
|
||||||
|
app.rs application state: settings (ArcSwap), IMAP pool, metrics, lockouts
|
||||||
|
config.rs settings model (serde, validation, merging, secrets)
|
||||||
|
db.rs SQLite (sqlx, WAL), migrations in migrations/, housekeeping
|
||||||
|
server.rs HTTP/1.1 + HTTP/2 listener, optional TLS, graceful shutdown
|
||||||
|
wbxml/ WBXML encoder/decoder + MS-ASWBXML code pages
|
||||||
|
eas/ ActiveSync protocol
|
||||||
|
mod.rs request parsing (plain/base64 query), auth, provisioning, dispatcher
|
||||||
|
state.rs SyncKey {uuid}N and states (item fingerprints)
|
||||||
|
folders.rs FolderSync / Create / Update / Delete
|
||||||
|
sync.rs Sync, GetItemEstimate, MoveItems
|
||||||
|
ping.rs Ping and the shared wait-for-changes loop (hanging Sync)
|
||||||
|
provision.rs policies, remote wipe
|
||||||
|
mailcmd.rs SendMail / SmartReply / SmartForward / MeetingResponse
|
||||||
|
itemops.rs ItemOperations, GetAttachment
|
||||||
|
misc.rs Settings, Search, ResolveRecipients
|
||||||
|
convert/ RFC 5322 / iCalendar / vCard <-> ApplicationData
|
||||||
|
backend/
|
||||||
|
imap.rs own async IMAP client (literals, UIDPLUS, MOVE, SPECIAL-USE)
|
||||||
|
dav.rs CalDAV/CardDAV client
|
||||||
|
smtp.rs SMTP (lettre) / sendmail
|
||||||
|
ical.rs iCalendar/vCard parser and serializer
|
||||||
|
tz.rs EAS <-> IANA time zones, dates
|
||||||
|
mime.rs MIME building (SmartReply/Forward)
|
||||||
|
mod.rs session: folder hierarchy, fingerprint listings, operations
|
||||||
|
autodiscover.rs
|
||||||
|
admin.rs panel API, sessions, CSRF, SSE, Prometheus
|
||||||
|
web/ panel (dependency-free HTML/CSS/JS, embedded in the binary)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Synchronisation
|
||||||
|
|
||||||
|
The device state of a folder is a map `item id -> fingerprint` (IMAP: flags; DAV: ETag) stored under the key
|
||||||
|
`{uuid}N`. Sync compares the backend listing with the state and sends the differences (deletes -> changes ->
|
||||||
|
adds, newest mail first) within `WindowSize`. The new state is stored as `N+1`; the previous one is kept to
|
||||||
|
handle lost responses (the device replays the old key). Client adds are idempotent thanks to the `ClientId` map.
|
||||||
|
|
||||||
|
Ping keeps a folder signature (IMAP `STATUS UIDNEXT/MESSAGES/UNSEEN/HIGHESTMODSEQ`, DAV CTag); a full listing
|
||||||
|
comparison only happens when the signature changes.
|
||||||
|
|
||||||
|
Collection ids that a device uses but that do not exist (Z-Push leftovers) are remembered per device and sent
|
||||||
|
as FolderHierarchy Delete on the next FolderSync.
|
||||||
|
|
||||||
|
## Development environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install dovecot-imapd # any dovecot >= 2.3
|
||||||
|
pip install radicale aiosmtpd
|
||||||
|
./dev.sh backends # Dovecot :11143, Radicale :15232, SMTP sink :12525
|
||||||
|
./dev.sh seed && ./dev.sh # ActivePush :8081 / panel :9091
|
||||||
|
./dev.sh e2e # test of every command against live backends
|
||||||
|
```
|
||||||
|
|
||||||
|
Test accounts: `jan`, `anna`, `biuro`, `kasia` `@test.local`, password `Test-Password-123`.
|
||||||
|
|
||||||
|
`./dev.sh lint` - rustfmt, clippy `-D warnings`, JS syntax check and WBXML tag name check
|
||||||
|
(`scripts/check-wbxml-names.py`). `./dev.sh test` - unit tests (WBXML, IMAP tokenizer, UTF-7, calendar/contact/
|
||||||
|
task conversions, time zones, query parser, CIDR, MIME).
|
||||||
|
|
||||||
|
Protocol debugging: add the login to **ActiveSync -> Logging -> Debug users**; decoded WBXML of requests and
|
||||||
|
responses goes to the log.
|
||||||
|
|
||||||
|
Simulating devices: `active-push probe --device-id F2LX8KQ1N72J --device-type iPhone --model "iPhone 15 Pro" ...`.
|
||||||
|
|
||||||
|
## Documentation screenshots
|
||||||
|
|
||||||
|
`tools/dev/screenshots.js` (Playwright) regenerates `docs/images/` from a running dev instance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/dev/screenshots.js http://127.0.0.1:9091 admin 'password'
|
||||||
|
```
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux x86_64 (the `.deb` needs glibc >= 2.30: Debian 11+, Ubuntu 20.04+). No OpenSSL or PHP - TLS is
|
||||||
|
rustls and SQLite is built in.
|
||||||
|
- IMAP server (Dovecot recommended: `UIDPLUS`, `MOVE`, `SPECIAL-USE`, `CONDSTORE`, `UNSELECT`).
|
||||||
|
- SMTP submission (Postfix on 587 with SASL) or a local `sendmail`.
|
||||||
|
- Optional CalDAV/CardDAV.
|
||||||
|
- A TLS reverse proxy (nginx, RustProxy, Apache). ActivePush listens on `127.0.0.1` only by default.
|
||||||
|
|
||||||
|
## Debian package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh deb # on the build machine (Rust stable; cargo-zigbuild makes it portable)
|
||||||
|
sudo apt install ./dist/active-push_*_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
| Path | Content |
|
||||||
|
|---|---|
|
||||||
|
| `/usr/bin/active-push` | binary (server + CLI) |
|
||||||
|
| `/lib/systemd/system/active-push.service` | service with systemd sandboxing |
|
||||||
|
| `/etc/active-push/environment` | listen addresses, panel TLS, log level (conffile) |
|
||||||
|
| `/var/lib/active-push/active-push.db` | SQLite: settings, devices, states, statistics, audit (0600) |
|
||||||
|
| `/usr/share/doc/active-push/` | documentation and nginx examples |
|
||||||
|
| `/usr/share/active-push/zpush-import.py` | Z-Push configuration importer |
|
||||||
|
|
||||||
|
`postinst` creates the `active-push` system user and enables and starts the service. Upgrading the package
|
||||||
|
restarts the service (push connections are released and devices reconnect). `apt purge` also removes data.
|
||||||
|
|
||||||
|
### Portable builds
|
||||||
|
|
||||||
|
`scripts/build-deb.sh` uses `cargo zigbuild` with a glibc 2.31 baseline when it is installed
|
||||||
|
(`pip install ziglang && cargo install cargo-zigbuild`), otherwise a normal `cargo build`. The `libc6`
|
||||||
|
dependency is computed from the binary's symbols. `GLIBC_TARGET=0` forces a native build.
|
||||||
|
|
||||||
|
### First administrator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u active-push active-push admin setup-token # one-time token, valid 60 minutes
|
||||||
|
ssh -L 9091:127.0.0.1:9091 admin@server # from your workstation
|
||||||
|
# browser: http://127.0.0.1:9091 -> paste the token, choose login and password (min. 12 characters)
|
||||||
|
```
|
||||||
|
|
||||||
|
Without a browser: `sudo -u active-push active-push admin init --username admin`.
|
||||||
|
Lost password: `sudo -u active-push active-push admin reset-password` (revokes all sessions).
|
||||||
|
Always run CLI commands as `active-push`; the binary refuses to touch a data directory owned by another user.
|
||||||
|
|
||||||
|
## nginx
|
||||||
|
|
||||||
|
Replace the Z-Push fastcgi locations with the snippet from `deploy/nginx-active-push.conf`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location = /Microsoft-Server-ActiveSync {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3660s; # Ping holds the request for up to ~59 minutes
|
||||||
|
proxy_send_timeout 3660s;
|
||||||
|
proxy_buffering off;
|
||||||
|
client_max_body_size 64m;
|
||||||
|
}
|
||||||
|
location = /AutoDiscover/AutoDiscover.xml { include snippets/active-push-autodiscover.conf; }
|
||||||
|
location = /Autodiscover/Autodiscover.xml { include snippets/active-push-autodiscover.conf; }
|
||||||
|
location = /autodiscover/autodiscover.xml { include snippets/active-push-autodiscover.conf; }
|
||||||
|
|
||||||
|
# health check for monitoring / load balancers (no authentication, no data)
|
||||||
|
location = /activesync-healthz {
|
||||||
|
proxy_pass http://127.0.0.1:8081/healthz;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`snippets/active-push-autodiscover.conf` is `deploy/active-push-autodiscover.conf`.
|
||||||
|
**Important:** `X-Forwarded-For $remote_addr` (overwrite, not append) together with the default trusted proxy
|
||||||
|
`127.0.0.1/32` gives the real client IP for lockouts and logs.
|
||||||
|
|
||||||
|
Port 8081 answers `/` with a short text, `/healthz` with `ok` (also checks the database) and anything else
|
||||||
|
with a plain 404.
|
||||||
|
|
||||||
|
## RustProxy
|
||||||
|
|
||||||
|
Create a vhost for the mail domain that routes `/Microsoft-Server-ActiveSync` and the three autodiscover paths
|
||||||
|
to `http://127.0.0.1:8081` with a response timeout of at least 3660 s and response buffering disabled; point a
|
||||||
|
health check at `/healthz`. RustProxy sets `X-Forwarded-For`, so its address must be in **Security -> Trusted
|
||||||
|
proxies** (loopback by default).
|
||||||
|
|
||||||
|
## Postfix (submission)
|
||||||
|
|
||||||
|
Recommended: SMTP on 587 authenticated with the device user's credentials (`auth: user_credentials`). With
|
||||||
|
`smtpd_sender_login_maps` + `reject_sender_login_mismatch` the mail server enforces the sender as well.
|
||||||
|
The `sendmail` method needs `NoNewPrivileges=false` and `ReadWritePaths=/var/spool/postfix/maildrop` in the unit
|
||||||
|
(setgid `postdrop`), which is why it is not recommended.
|
||||||
|
|
||||||
|
## Panel outside localhost
|
||||||
|
|
||||||
|
The panel refuses to start on a non-loopback address unless allowed:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/active-push/environment
|
||||||
|
ACTIVE_PUSH_ALLOW_REMOTE_ADMIN=true
|
||||||
|
ACTIVE_PUSH_ADMIN_LISTEN=0.0.0.0:9091
|
||||||
|
ACTIVE_PUSH_ADMIN_TLS_CERT=/etc/active-push/tls/fullchain.pem
|
||||||
|
ACTIVE_PUSH_ADMIN_TLS_KEY=/etc/active-push/tls/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
and set **Security -> Networks allowed to use the panel**. Certificates must be readable by the
|
||||||
|
`active-push` group (e.g. copied by a certbot deploy hook).
|
||||||
|
|
||||||
|
## Upgrades
|
||||||
|
|
||||||
|
Install the new package; the schema is migrated automatically on start. Settings, devices and states are kept.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
See [LOGS.md](LOGS.md#backups). Losing the database never loses mail - devices resynchronise.
|
||||||
|
|
||||||
|
## Building from source without a package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
sudo install -m 0755 target/release/active-push /usr/bin/active-push
|
||||||
|
sudo adduser --system --group --home /var/lib/active-push active-push
|
||||||
|
sudo install -d -o active-push -g active-push -m 0700 /var/lib/active-push
|
||||||
|
sudo install -m 0644 deploy/active-push.service /etc/systemd/system/
|
||||||
|
sudo install -d -m 0750 -g active-push /etc/active-push
|
||||||
|
sudo install -m 0640 -g active-push deploy/environment.example /etc/active-push/environment
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl enable --now active-push
|
||||||
|
```
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Logging, retention and database maintenance
|
||||||
|
|
||||||
|
ActivePush is designed so that nothing grows without bounds. This page lists every place where data
|
||||||
|
accumulates and how it is limited.
|
||||||
|
|
||||||
|
## Server log
|
||||||
|
|
||||||
|
- Written to **stdout/stderr**, collected by **journald** under the systemd unit. ActivePush does not write
|
||||||
|
log files itself, so there is nothing to rotate with logrotate.
|
||||||
|
- Level: `RUST_LOG` in `/etc/active-push/environment` (default `active_push=info,warn`).
|
||||||
|
`ACTIVE_PUSH_LOG_JSON=true` switches to JSON lines (ELK, Loki, Vector).
|
||||||
|
- Size is controlled by journald. Recommended drop-in:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/journald.conf.d/size.conf
|
||||||
|
[Journal]
|
||||||
|
SystemMaxUse=500M
|
||||||
|
SystemKeepFree=1G
|
||||||
|
MaxRetentionSec=30day
|
||||||
|
```
|
||||||
|
|
||||||
|
then `systemctl restart systemd-journald`. Read the log with `journalctl -u active-push -f`.
|
||||||
|
- Volume at `info` is roughly one line per notable event (new device, provisioning, failed login, sent
|
||||||
|
mail, errors) - routine Sync/Ping requests are not logged individually.
|
||||||
|
- **Debug WBXML** (`logging.log_wbxml` or `logging.debug_users`) logs full requests and responses including
|
||||||
|
message bodies. Enable it only for a short diagnosis and for chosen mailboxes.
|
||||||
|
|
||||||
|
If you redirect output to a file yourself (e.g. a container without journald), rotate it with
|
||||||
|
`copytruncate`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/log/active-push/active-push.log {
|
||||||
|
daily
|
||||||
|
rotate 14
|
||||||
|
compress
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
copytruncate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## In-memory buffers (panel)
|
||||||
|
|
||||||
|
| Buffer | Limit |
|
||||||
|
|---|---|
|
||||||
|
| Log viewer | `logging.ring_size` lines (default 2000, 100..100000) |
|
||||||
|
| Live request stream | last 500 requests |
|
||||||
|
| Minute statistics | 24 hours (1440 buckets) |
|
||||||
|
| Active mailbox tracking | entries older than 24 h are pruned every minute |
|
||||||
|
| Failed login counters / lockouts / login cache | expire automatically, pruned every minute |
|
||||||
|
| Loop detection, stale collections, time zone cache | bounded and expiring |
|
||||||
|
|
||||||
|
All of these are lost on restart by design; persistent history is in the database.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
`/var/lib/active-push/active-push.db` (SQLite, WAL). Hourly housekeeping:
|
||||||
|
|
||||||
|
| Data | Retention |
|
||||||
|
|---|---|
|
||||||
|
| Hourly statistics | `logging.stats_retention_days` (default 90) |
|
||||||
|
| Audit log | `logging.audit_retention_days` (default 365) |
|
||||||
|
| Panel sessions, setup tokens | removed when expired |
|
||||||
|
| Devices not seen for `panel.device_inactive_days` (default 180, `0` = never) | removed with their sync states and folder statistics; a device that returns later simply resynchronises |
|
||||||
|
| Sync states | at most two per folder and device (current + previous for retries); removed with the device, on folder deletion and on FolderSync reset; states untouched for more than 400 days are dropped |
|
||||||
|
| Folder/item id maps | removed for mailboxes that no longer have any device |
|
||||||
|
| WAL file | checkpointed and truncated every hour |
|
||||||
|
| Free pages | incremental auto-vacuum (databases from 0.1.0 are converted once automatically) |
|
||||||
|
|
||||||
|
Typical size: a few hundred kilobytes per device, mostly sync states of large mail folders. The panel shows
|
||||||
|
the database and WAL size on the Overview page (**Server** card).
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 /var/lib/active-push/active-push.db ".backup '/root/active-push-$(date +%F).db'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Losing the database never loses mail data: devices perform a full resynchronisation.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Migrating from Z-Push
|
||||||
|
|
||||||
|
## 1. Import the configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /usr/share/active-push/zpush-import.py /usr/share/z-push > zpush.json # source installation
|
||||||
|
python3 /usr/share/active-push/zpush-import.py /etc/z-push > zpush.json # Debian packages (*.conf.php)
|
||||||
|
less zpush.json # review and complete
|
||||||
|
sudo -u active-push active-push config apply zpush.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The importer reads `config.php`, `backend/{imap,caldav,carddav,combined}/config.php`, `autodiscover/config.php`
|
||||||
|
and `policies.ini`. Comments on stderr point out what needs attention (e.g. `IMAP_SMTP_METHOD = 'mail'` ->
|
||||||
|
sendmail; switching to SMTP submission on 587 is recommended).
|
||||||
|
|
||||||
|
## 2. Switch the traffic
|
||||||
|
|
||||||
|
1. Install ActivePush, create the administrator, verify the backends with **Test connection** in the panel.
|
||||||
|
2. Check the protocol with a real account:
|
||||||
|
`active-push probe --url https://mail.example.com/Microsoft-Server-ActiveSync --user jan@example.com`
|
||||||
|
(through nginx after the switch) or directly against `http://127.0.0.1:8081/...` before it.
|
||||||
|
3. In nginx replace the `fastcgi_pass ... z-push/index.php` and `autodiscover.php` blocks with `proxy_pass`
|
||||||
|
(see INSTALL.md), then `nginx -t && systemctl reload nginx`.
|
||||||
|
4. Disable the Z-Push PHP-FPM pool and the `z-push-admin` cron jobs.
|
||||||
|
|
||||||
|
## 3. What happens to devices
|
||||||
|
|
||||||
|
Z-Push states are not transferred (their format depends on the PHP backends). On the first connection:
|
||||||
|
|
||||||
|
- the device sends its old `X-MS-PolicyKey` -> new provisioning (HTTP 449, or status 142/144 for AS >= 14),
|
||||||
|
- the old hierarchy `SyncKey` -> `FolderSync` status 9 -> the device downloads the folder list again,
|
||||||
|
- old folder keys -> `Sync` status 3 -> full folder synchronisation,
|
||||||
|
- folder ids that only existed in Z-Push (e.g. `a6271970`) -> `Sync` status 12, and the next FolderSync sends a
|
||||||
|
**Delete** for them so the device stops using them.
|
||||||
|
|
||||||
|
iOS, Android (Gmail, Samsung, Nine), Outlook and Windows Mail handle this automatically. Large mailboxes may
|
||||||
|
take a few minutes - limit `sync.filter_time_max` or the policy `maxemailagefilter` if needed.
|
||||||
|
|
||||||
|
## 4. `z-push-admin` equivalents
|
||||||
|
|
||||||
|
| z-push-admin | ActivePush |
|
||||||
|
|---|---|
|
||||||
|
| `-a list` | `active-push device list` / panel **Devices** |
|
||||||
|
| `-a wipe -d ID` | `active-push device wipe <id>` / **Remote wipe** button |
|
||||||
|
| `-a remove -d ID` | `active-push device remove <id>` |
|
||||||
|
| `-a resync -d ID` | `active-push device resync <id>` / **Full resync** |
|
||||||
|
| `-a clearloop` | automatic - loop counter in the device details |
|
||||||
|
| `-a fixstates` | `panel.device_inactive_days` + hourly housekeeping |
|
||||||
|
| `z-push-top` | panel **Live** |
|
||||||
|
|
||||||
|
## 5. Functional differences
|
||||||
|
|
||||||
|
- **Kopano/Zarafa (MAPI)** is not supported - only IMAP/SMTP + CalDAV/CardDAV (`combined`).
|
||||||
|
- **LDAP/SQL as sender source** (`IMAP_FROM_SQL_*`, `IMAP_FROM_LDAP_*`) is replaced by `general.email_template`.
|
||||||
|
- **searchldap** -> GAL through CardDAV (`carddav.gal_path`); SOGo/Nextcloud expose the directory as a book.
|
||||||
|
- **Out of office** - reading returns "disabled", setting returns an error; configure vacation in Sieve.
|
||||||
|
- **AS 16.x** is not announced (like Z-Push with IMAP by default) - devices use 14.1.
|
||||||
|
- Notes and SMS are not synchronised (Z-Push IMAP did not do this either).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Administration panel
|
||||||
|
|
||||||
|
The panel is a separate listener (default `127.0.0.1:9091`) embedded in the binary. Reach it through an SSH
|
||||||
|
tunnel (`ssh -L 9091:127.0.0.1:9091 server`) or a VPN. It is in English by default; switch to Polish with the
|
||||||
|
language selector (remembered per browser). Settings pages apply changes immediately, without a restart.
|
||||||
|
|
||||||
|
## First login
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
On the first start there is no administrator. Generate a one-time token on the server and paste it together
|
||||||
|
with the new login and password (at least 12 characters):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u active-push active-push admin setup-token # valid 60 minutes, single use
|
||||||
|
```
|
||||||
|
|
||||||
|
Without a browser: `sudo -u active-push active-push admin init --username admin`.
|
||||||
|
|
||||||
|
## 01 Overview
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **Tiles** - requests per minute, active push connections, **devices** (physical phones, counted by DeviceId),
|
||||||
|
**mailboxes** (logins), mails sent, errors, failed logins and transfer. One person with several mailboxes on
|
||||||
|
one phone counts as one device and several mailboxes; the devices tile also shows the number of
|
||||||
|
partnerships (device x mailbox pairs).
|
||||||
|
- **Charts** - traffic, synchronised items and data per minute (60 min / 6 h / 24 h), requests per hour for the
|
||||||
|
last 7 days. Hover a chart to read exact values.
|
||||||
|
- **Commands**, **device types** and **server** (version, uptime, memory, IMAP pool, database and WAL size,
|
||||||
|
configured backends).
|
||||||
|
|
||||||
|
## 02 Live
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Push connections (Ping or hanging Sync) with their heartbeat progress, requests in flight and a live stream of
|
||||||
|
finished requests (Server-Sent Events). Filter the stream by mailbox, device or command; pause it while reading.
|
||||||
|
|
||||||
|
## 03 Devices
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
One row per **physical device**. Every mailbox configured on it is a chip; a green dot means that mailbox
|
||||||
|
currently holds a push connection. Filters: push, pending approval, blocked, inactive for 30 days.
|
||||||
|
|
||||||
|
Click a mailbox chip (or the row) to open the partnership:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- details reported by the device (model, OS, IMEI, user agent, protocol, policy),
|
||||||
|
- per-folder statistics (items, sent/received, last sync, window size, loop detection hits),
|
||||||
|
- recent requests and free-form notes,
|
||||||
|
- actions: **approve**, **full resync**, **force re-provision**, **block/unblock**, **remote wipe** (confirmed by
|
||||||
|
typing the device id), **cancel wipe**, **show recovery password**, **remove partnership**.
|
||||||
|
|
||||||
|
All actions are written to the audit log.
|
||||||
|
|
||||||
|
## 04 Mailboxes
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Every login that synchronises through ActiveSync, with its devices, the **other mailboxes on the same device**
|
||||||
|
(useful to see one person's accounts together), push connections, requests, last activity and effective policy.
|
||||||
|
Blocking a mailbox applies immediately on every device. Active login lockouts can be cleared here.
|
||||||
|
|
||||||
|
## 05 Backends
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
IMAP, SMTP, CalDAV and CardDAV. Each section has a **Test connection** button that uses the values currently in
|
||||||
|
the form (also unsaved ones) and an optional test login; the password is never stored.
|
||||||
|
|
||||||
|
## 06 ActiveSync
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Login format and default domain, e-mail address template, protocol version, synchronisation limits, push
|
||||||
|
intervals, Autodiscover, logging and retention.
|
||||||
|
|
||||||
|
## 07 Policies
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Provisioning switch, loose provisioning, named policies with every `policies.ini` option and per-mailbox
|
||||||
|
assignments (`login=policy`, one per line). Changing a policy makes devices accept it again.
|
||||||
|
|
||||||
|
## 08 Security
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Login lockout thresholds, trusted proxies and client IP header, network allow lists for ActiveSync and the
|
||||||
|
panel, allowed/blocked mailboxes and device types, device approval, panel session lifetime, Prometheus token,
|
||||||
|
administrator password change and signing out other sessions.
|
||||||
|
|
||||||
|
## 09 Logs and audit
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Recent server log lines (level filter, search, auto refresh) and the audit trail of administrative actions.
|
||||||
|
The log view is an in-memory ring buffer; the full log goes to journald. See [LOGS.md](LOGS.md).
|
||||||
|
|
||||||
|
## Themes and small screens
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The theme button switches between dark and light. On narrow screens the navigation collapses into a menu.
|
||||||
|
|
||||||
|
<img src="images/mobile.png" alt="Mobile" width="320">
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
## Model
|
||||||
|
|
||||||
|
- **Devices** authenticate with Basic Auth (required by the protocol) - always behind TLS (reverse proxy).
|
||||||
|
The password is verified by logging in to IMAP and is passed to SMTP/DAV only within the same request. It is
|
||||||
|
never stored. The successful login cache keeps only HMAC-SHA256(login, password) with a random key generated
|
||||||
|
at process start.
|
||||||
|
- **The IMAP pool** keeps authenticated connections keyed by the same HMAC - a connection is only reused for a
|
||||||
|
request with identical credentials.
|
||||||
|
- **The panel** is a separate listener (loopback by default; another address requires explicit consent).
|
||||||
|
Administrator passwords: argon2id. Session: random 256-bit token in an `HttpOnly; SameSite=Strict` cookie
|
||||||
|
(`Secure` over HTTPS); only its SHA-256 is stored. Every modifying request needs the CSRF header. The first
|
||||||
|
administrator can only be created with a one-time token from the CLI (valid 60 minutes).
|
||||||
|
- **Panel headers:** strict CSP (`script-src 'self'`, no inline code), `X-Frame-Options: DENY`, `nosniff`,
|
||||||
|
`Referrer-Policy: no-referrer`, COOP.
|
||||||
|
|
||||||
|
## Abuse protection
|
||||||
|
|
||||||
|
- Lockout after `auth_fail_max` failures per account and `3x` per IP within the window; HTTP 429 with
|
||||||
|
`Retry-After`. Other accounts from the same IP keep working until the IP limit is reached.
|
||||||
|
- fail2ban friendly log line:
|
||||||
|
`authentication failed user=jan@example.com ip=203.0.113.7 service=activesync`
|
||||||
|
```ini
|
||||||
|
# /etc/fail2ban/filter.d/active-push.conf
|
||||||
|
[Definition]
|
||||||
|
failregex = authentication failed user=\S+ ip=<HOST> service=
|
||||||
|
journalmatch = _SYSTEMD_UNIT=active-push.service
|
||||||
|
```
|
||||||
|
- Panel: 5 failed logins in 10 minutes lock the IP for 15 minutes; constant verification cost even for unknown
|
||||||
|
accounts.
|
||||||
|
- Limits: request size (`sync.max_request_mb`), attachments, messages, WBXML depth and element count, IMAP
|
||||||
|
response size, timeouts on every backend operation, HTTP header read timeout.
|
||||||
|
- Unknown ActiveSync command names are counted as `Unknown` (no unbounded metric labels).
|
||||||
|
- XML (Autodiscover, WebDAV) is parsed without DTD/entities (XXE, billion laughs).
|
||||||
|
- The DAV client never follows `href`s to another host (SSRF).
|
||||||
|
- Logins, device ids and device types are validated; SQL parameters are always bound.
|
||||||
|
- `smtp.enforce_from` rewrites `From` to the account address; `Bcc` is stripped from the transmitted copy.
|
||||||
|
|
||||||
|
## Device control
|
||||||
|
|
||||||
|
- Provisioning policies (password, encryption, camera, maximum mail age ...). Changing a policy invalidates
|
||||||
|
keys - devices must accept the policy again.
|
||||||
|
- Remote wipe (confirmed by typing the device id), device block, approval of new devices, mailbox block,
|
||||||
|
blocked device types/User-Agents.
|
||||||
|
- Every administrative action is written to the audit log (who, what, when, from where).
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
|
||||||
|
The service runs as `active-push` without privileges or capabilities, with `ProtectSystem=strict`,
|
||||||
|
`ProtectHome`, `PrivateTmp`, `PrivateDevices`, `MemoryDenyWriteExecute`, a system call filter and write access
|
||||||
|
only to `/var/lib/active-push`. The database is `0600`, the directory `0700`, `UMask=0077`.
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
1. TLS only at the reverse proxy, with HSTS; ActivePush on `127.0.0.1`.
|
||||||
|
2. Panel through an SSH tunnel or VPN. If exposed - TLS + `panel.allowed_networks`.
|
||||||
|
3. SMTP submission with `reject_sender_login_mismatch`.
|
||||||
|
4. Enable `security.require_device_approval` in company environments.
|
||||||
|
5. Set a Prometheus token only if you scrape metrics; the endpoint then lives on the panel port.
|
||||||
|
6. `logging.log_wbxml` contains message content - only for short diagnosis.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## Health and diagnostics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:8081/healthz # "ok" when the process and database respond
|
||||||
|
journalctl -u active-push -f # server log
|
||||||
|
sudo -u active-push active-push device list
|
||||||
|
active-push probe --url https://mail.example.com/Microsoft-Server-ActiveSync --user jan@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
`probe` performs OPTIONS, Provision, FolderSync and an Inbox Sync like a phone would. Use `--device-id`,
|
||||||
|
`--device-type` and `--model` to imitate a specific device. For protocol-level detail add the mailbox to
|
||||||
|
**ActiveSync -> Logging -> Debug users**; decoded WBXML of every request/response then appears in the log.
|
||||||
|
|
||||||
|
## After migrating from Z-Push: "collection ... not found" in the live stream
|
||||||
|
|
||||||
|
Phones may keep folder ids created by Z-Push (e.g. `a6271970`). ActivePush answers such a Sync with status 12
|
||||||
|
(hierarchy changed), remembers the id and sends a FolderHierarchy **Delete** for it on the device's next
|
||||||
|
FolderSync, so the phone drops the phantom folder and continues with the real ones. If a device still loops
|
||||||
|
after a few minutes, use **Full resync** in the device dialog or remove and re-add the account on the phone.
|
||||||
|
|
||||||
|
## Device keeps asking for the password / HTTP 401
|
||||||
|
|
||||||
|
- Wrong password, or the login format does not match IMAP (`general.login_format`, `default_domain`).
|
||||||
|
- The account or IP may be locked out: **Mailboxes -> Active login lockouts** shows and clears lockouts.
|
||||||
|
- Check `authentication failed user=... ip=...` lines in the log.
|
||||||
|
|
||||||
|
## HTTP 449 or status 142/144
|
||||||
|
|
||||||
|
The device has not accepted the current policy. It normally re-provisions automatically. If a device cannot
|
||||||
|
apply the policy (e.g. it does not support encryption), relax the policy or enable loose provisioning.
|
||||||
|
|
||||||
|
## Push is late
|
||||||
|
|
||||||
|
Push latency is at most `ping.check_interval_secs` (default 30 s). The reverse proxy must allow long requests
|
||||||
|
(`proxy_read_timeout 3660s`, buffering off). The **Live** page shows every held Ping and its heartbeat.
|
||||||
|
|
||||||
|
## HTTP 503 with Retry-After
|
||||||
|
|
||||||
|
The IMAP server was unreachable during the request. Check the **Backends** page test buttons.
|
||||||
|
|
||||||
|
## Panel is not reachable
|
||||||
|
|
||||||
|
The panel listens on loopback by default; use `ssh -L 9091:127.0.0.1:9091 server`. Starting it on another
|
||||||
|
address requires `ACTIVE_PUSH_ALLOW_REMOTE_ADMIN=true` (preferably with TLS). Lost password:
|
||||||
|
`sudo -u active-push active-push admin reset-password`.
|
||||||
|
|
||||||
|
## "belongs to uid ..." when running a command
|
||||||
|
|
||||||
|
CLI commands must run as the service user so database files keep the right owner:
|
||||||
|
`sudo -u active-push active-push ...`.
|
||||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,140 @@
|
|||||||
|
-- ActivePush schema v1
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
json TEXT NOT NULL,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_login INTEGER,
|
||||||
|
password_changed_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||||
|
csrf TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS setup_token (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
device_type TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
imei TEXT NOT NULL DEFAULT '',
|
||||||
|
friendly_name TEXT NOT NULL DEFAULT '',
|
||||||
|
os TEXT NOT NULL DEFAULT '',
|
||||||
|
os_language TEXT NOT NULL DEFAULT '',
|
||||||
|
phone_number TEXT NOT NULL DEFAULT '',
|
||||||
|
mobile_operator TEXT NOT NULL DEFAULT '',
|
||||||
|
as_version TEXT NOT NULL DEFAULT '',
|
||||||
|
policy_key INTEGER NOT NULL DEFAULT 0,
|
||||||
|
policy_temp_key INTEGER NOT NULL DEFAULT 0,
|
||||||
|
policy_name TEXT NOT NULL DEFAULT '',
|
||||||
|
policy_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
wipe_status INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wipe_requested_at INTEGER,
|
||||||
|
wipe_requested_by TEXT,
|
||||||
|
wipe_completed_at INTEGER,
|
||||||
|
blocked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
approved INTEGER NOT NULL DEFAULT 1,
|
||||||
|
first_seen INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL,
|
||||||
|
last_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
last_command TEXT NOT NULL DEFAULT '',
|
||||||
|
requests INTEGER NOT NULL DEFAULT 0,
|
||||||
|
recovery_password TEXT NOT NULL DEFAULT '',
|
||||||
|
ping_folders TEXT NOT NULL DEFAULT '',
|
||||||
|
ping_lifetime INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_sync_request TEXT NOT NULL DEFAULT '',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
UNIQUE (device_id, username)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS devices_user ON devices(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS devices_seen ON devices(last_seen);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_states (
|
||||||
|
device_rowid INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
uuid TEXT NOT NULL,
|
||||||
|
counter INTEGER NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (device_rowid, collection, uuid, counter)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS folder_map (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
backend_id TEXT NOT NULL,
|
||||||
|
short_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (username, backend_id),
|
||||||
|
UNIQUE (username, short_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_map (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
folder TEXT NOT NULL,
|
||||||
|
long_id TEXT NOT NULL,
|
||||||
|
short_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (username, folder, long_id),
|
||||||
|
UNIQUE (username, folder, short_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS folder_stats (
|
||||||
|
device_rowid INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
folder_name TEXT NOT NULL DEFAULT '',
|
||||||
|
folder_class TEXT NOT NULL DEFAULT '',
|
||||||
|
last_sync INTEGER NOT NULL DEFAULT 0,
|
||||||
|
items_out INTEGER NOT NULL DEFAULT 0,
|
||||||
|
items_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
item_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_status INTEGER NOT NULL DEFAULT 0,
|
||||||
|
window_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
loop_hits INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (device_rowid, collection)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stats_hourly (
|
||||||
|
hour INTEGER PRIMARY KEY,
|
||||||
|
requests INTEGER NOT NULL DEFAULT 0,
|
||||||
|
errors INTEGER NOT NULL DEFAULT 0,
|
||||||
|
auth_failures INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_out INTEGER NOT NULL DEFAULT 0,
|
||||||
|
items_out INTEGER NOT NULL DEFAULT 0,
|
||||||
|
items_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mails_sent INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
commands TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL DEFAULT '',
|
||||||
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
|
details TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_ts ON audit_log(ts);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
|
profile = "minimal"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a Debian/Ubuntu package with dpkg-deb only (no debhelper needed).
|
||||||
|
# scripts/build-deb.sh build release binary for the host and package it
|
||||||
|
# scripts/build-deb.sh --no-build package an existing target/release/active-push
|
||||||
|
# TARGET=aarch64-unknown-linux-gnu scripts/build-deb.sh (cross toolchain must be installed)
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
fail() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||||
|
command -v dpkg-deb >/dev/null || fail "dpkg-deb is required (apt install dpkg)"
|
||||||
|
|
||||||
|
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
|
||||||
|
REVISION="${DEB_REVISION:-1}"
|
||||||
|
BUILD=1
|
||||||
|
[[ "${1:-}" == "--no-build" ]] && BUILD=0
|
||||||
|
HOST_ARCH="$(uname -m)"
|
||||||
|
# Portable builds: link against an old glibc with cargo-zigbuild when available
|
||||||
|
# (pip install ziglang && cargo install cargo-zigbuild). GLIBC_TARGET=0 disables it.
|
||||||
|
GLIBC_TARGET="${GLIBC_TARGET:-2.31}"
|
||||||
|
TARGET="${TARGET:-}"
|
||||||
|
ZIG=0
|
||||||
|
if [[ -z "$TARGET" && "$GLIBC_TARGET" != "0" ]] && command -v cargo-zigbuild >/dev/null 2>&1; then
|
||||||
|
TARGET="${HOST_ARCH}-unknown-linux-gnu"
|
||||||
|
ZIG=1
|
||||||
|
fi
|
||||||
|
case "${TARGET:-$HOST_ARCH}" in
|
||||||
|
x86_64*) ARCH=amd64 ;;
|
||||||
|
aarch64*) ARCH=arm64 ;;
|
||||||
|
armv7*) ARCH=armhf ;;
|
||||||
|
*) ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)" ;;
|
||||||
|
esac
|
||||||
|
if [[ -n "${BIN:-}" ]]; then
|
||||||
|
:
|
||||||
|
elif [[ -n "$TARGET" ]]; then
|
||||||
|
BIN="${CARGO_TARGET_DIR:-target}/$TARGET/release/active-push"
|
||||||
|
else
|
||||||
|
BIN="${CARGO_TARGET_DIR:-target}/release/active-push"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $BUILD == 1 ]]; then
|
||||||
|
printf 'Building release binary %s...\n' "$VERSION"
|
||||||
|
if [[ $ZIG == 1 ]]; then
|
||||||
|
cargo zigbuild --release --locked --target "$TARGET.$GLIBC_TARGET"
|
||||||
|
elif [[ -n "$TARGET" ]]; then
|
||||||
|
cargo build --release --locked --target "$TARGET"
|
||||||
|
else
|
||||||
|
cargo build --release --locked
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[[ -x "$BIN" ]] || fail "binary $BIN not found"
|
||||||
|
|
||||||
|
# minimal libc6 version from the binary's versioned symbols
|
||||||
|
LIBC_MIN="2.31"
|
||||||
|
if command -v objdump >/dev/null; then
|
||||||
|
v="$(objdump -T "$BIN" 2>/dev/null | grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -V | tail -1)"
|
||||||
|
[[ -n "$v" ]] && LIBC_MIN="$v"
|
||||||
|
fi
|
||||||
|
printf 'Binary %s requires glibc >= %s\n' "$BIN" "$LIBC_MIN"
|
||||||
|
|
||||||
|
PKG="active-push_${VERSION}-${REVISION}_${ARCH}"
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
D="$STAGE/$PKG"
|
||||||
|
|
||||||
|
install -d -m 0755 "$D/DEBIAN" "$D/usr/bin" "$D/lib/systemd/system" "$D/etc/active-push" \
|
||||||
|
"$D/usr/share/doc/active-push/examples" "$D/usr/share/active-push"
|
||||||
|
install -m 0755 "$BIN" "$D/usr/bin/active-push"
|
||||||
|
strip --strip-unneeded "$D/usr/bin/active-push" 2>/dev/null || true
|
||||||
|
install -m 0644 deploy/active-push.service "$D/lib/systemd/system/active-push.service"
|
||||||
|
install -m 0640 deploy/environment.example "$D/etc/active-push/environment"
|
||||||
|
install -m 0644 deploy/nginx-active-push.conf "$D/usr/share/doc/active-push/examples/nginx-active-push.conf"
|
||||||
|
install -m 0644 deploy/active-push-autodiscover.conf "$D/usr/share/doc/active-push/examples/active-push-autodiscover.conf"
|
||||||
|
install -m 0644 README.md "$D/usr/share/doc/active-push/README.md"
|
||||||
|
for f in docs/*.md; do install -m 0644 "$f" "$D/usr/share/doc/active-push/"; done
|
||||||
|
install -m 0755 tools/zpush-import.py "$D/usr/share/active-push/zpush-import.py"
|
||||||
|
install -m 0644 LICENSE "$D/usr/share/doc/active-push/copyright"
|
||||||
|
gzip -9n -c CHANGELOG.md > "$D/usr/share/doc/active-push/changelog.gz"
|
||||||
|
|
||||||
|
SIZE="$(du -sk "$D" | cut -f1)"
|
||||||
|
cat > "$D/DEBIAN/control" <<EOF
|
||||||
|
Package: active-push
|
||||||
|
Version: ${VERSION}-${REVISION}
|
||||||
|
Section: mail
|
||||||
|
Priority: optional
|
||||||
|
Architecture: ${ARCH}
|
||||||
|
Maintainer: ActivePush maintainers <root@localhost>
|
||||||
|
Installed-Size: ${SIZE}
|
||||||
|
Depends: libc6 (>= ${LIBC_MIN}), adduser, ca-certificates
|
||||||
|
Recommends: nginx | apache2 | rust-proxy
|
||||||
|
Suggests: dovecot-imapd, postfix, radicale
|
||||||
|
Conflicts: z-push-common
|
||||||
|
Description: Exchange ActiveSync server for IMAP/SMTP, CalDAV and CardDAV
|
||||||
|
ActivePush lets phones and Outlook synchronise mail, calendars, tasks and
|
||||||
|
contacts over Exchange ActiveSync (2.5-14.1) with standard IMAP/SMTP,
|
||||||
|
CalDAV and CardDAV servers. It is a Rust rewrite of Z-Push with a built-in
|
||||||
|
administration panel (devices, remote wipe, policies, live statistics).
|
||||||
|
EOF
|
||||||
|
# Z-Push can be installed side by side; drop the Conflicts line if you rely on that.
|
||||||
|
sed -i '/^Conflicts: z-push-common/d' "$D/DEBIAN/control"
|
||||||
|
|
||||||
|
echo "/etc/active-push/environment" > "$D/DEBIAN/conffiles"
|
||||||
|
|
||||||
|
cat > "$D/DEBIAN/postinst" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
if ! getent group active-push >/dev/null; then addgroup --system active-push >/dev/null; fi
|
||||||
|
if ! getent passwd active-push >/dev/null; then
|
||||||
|
adduser --system --ingroup active-push --home /var/lib/active-push --no-create-home \
|
||||||
|
--shell /usr/sbin/nologin --gecos "ActivePush" active-push >/dev/null
|
||||||
|
fi
|
||||||
|
install -d -o active-push -g active-push -m 0700 /var/lib/active-push
|
||||||
|
chown root:active-push /etc/active-push /etc/active-push/environment
|
||||||
|
chmod 0750 /etc/active-push
|
||||||
|
chmod 0640 /etc/active-push/environment
|
||||||
|
if [ -d /run/systemd/system ]; then
|
||||||
|
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
systemctl enable --now active-push.service >/dev/null 2>&1 || true
|
||||||
|
echo ""
|
||||||
|
echo "ActivePush installed and started."
|
||||||
|
echo " ActiveSync listener : 127.0.0.1:8081 (proxy /Microsoft-Server-ActiveSync and autodiscover here)"
|
||||||
|
echo " Admin panel : http://127.0.0.1:9091/ (ssh -L 9091:127.0.0.1:9091 server)"
|
||||||
|
echo " First administrator : sudo -u active-push active-push admin setup-token"
|
||||||
|
echo " nginx example : /usr/share/doc/active-push/examples/nginx-active-push.conf"
|
||||||
|
echo " Import Z-Push config: python3 /usr/share/active-push/zpush-import.py /usr/share/z-push > zpush.json"
|
||||||
|
echo " sudo -u active-push active-push config apply zpush.json"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
systemctl try-restart active-push.service >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$D/DEBIAN/prerm" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ -d /run/systemd/system ] && [ "$1" = remove ]; then
|
||||||
|
systemctl disable --now active-push.service >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$D/DEBIAN/postrm" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "$1" = purge ]; then
|
||||||
|
rm -rf /etc/active-push
|
||||||
|
# device states, statistics and settings
|
||||||
|
rm -rf /var/lib/active-push
|
||||||
|
if getent passwd active-push >/dev/null; then deluser --system active-push >/dev/null 2>&1 || true; fi
|
||||||
|
if getent group active-push >/dev/null; then delgroup --system active-push >/dev/null 2>&1 || true; fi
|
||||||
|
fi
|
||||||
|
if [ -d /run/systemd/system ]; then systemctl daemon-reload >/dev/null 2>&1 || true; fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod 0755 "$D/DEBIAN/postinst" "$D/DEBIAN/prerm" "$D/DEBIAN/postrm"
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
dpkg-deb --root-owner-group -Zxz --build "$D" "dist/$PKG.deb" >/dev/null
|
||||||
|
sha256sum "dist/$PKG.deb" > "dist/$PKG.deb.sha256"
|
||||||
|
printf 'Package: dist/%s.deb\n' "$PKG"
|
||||||
|
dpkg-deb --info "dist/$PKG.deb" | sed -n '1,12p'
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify that every literal "Namespace:Tag" used in the sources exists in the WBXML code pages.
|
||||||
|
|
||||||
|
Element::new() panics on unknown names at runtime; this catches typos at build time.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
cp = (root / "src/wbxml/codepages.rs").read_text()
|
||||||
|
namespaces = re.findall(r'"([^"]+)"', re.search(r"NAMESPACES: &\[&str\] = &\[(.*?)\];", cp, re.S).group(1))
|
||||||
|
body = cp[cp.index("pub const PAGES"):]
|
||||||
|
known = set()
|
||||||
|
for i, chunk in enumerate(re.split(r"\n\s*// \d+ \w+\n", body)[1:]):
|
||||||
|
for tag in re.findall(r'\(0x[0-9A-Fa-f]+,\s*"([^"]+)"\)', chunk):
|
||||||
|
known.add(f"{namespaces[i]}:{tag}")
|
||||||
|
|
||||||
|
pattern = re.compile(r'"((?:%s):[A-Za-z0-9]+)"' % "|".join(re.escape(n) for n in namespaces))
|
||||||
|
bad = []
|
||||||
|
for f in (root / "src").rglob("*.rs"):
|
||||||
|
if f.name == "codepages.rs":
|
||||||
|
continue
|
||||||
|
for n, line in enumerate(f.read_text().splitlines(), 1):
|
||||||
|
for m in pattern.finditer(line):
|
||||||
|
if m.group(1) not in known:
|
||||||
|
bad.append(f"{f.relative_to(root)}:{n}: {m.group(1)}")
|
||||||
|
if bad:
|
||||||
|
print("Unknown WBXML tag names:\n " + "\n ".join(bad))
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"WBXML names: OK ({len(known)} known tags)")
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! Shared application state.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
use rand::RngCore;
|
||||||
|
|
||||||
|
use crate::backend::dav::DavClient;
|
||||||
|
use crate::backend::{ImapPool, Session};
|
||||||
|
use crate::config::Settings;
|
||||||
|
use crate::db::{self, Db};
|
||||||
|
use crate::logging::LogRing;
|
||||||
|
use crate::metrics::{AuthGuard, Metrics};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Boot {
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub eas_listen: SocketAddr,
|
||||||
|
pub admin_listen: SocketAddr,
|
||||||
|
pub allow_remote_admin: bool,
|
||||||
|
pub admin_tls: Option<(String, String)>,
|
||||||
|
pub eas_tls: Option<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub db: Db,
|
||||||
|
pub boot: Boot,
|
||||||
|
settings: ArcSwap<Settings>,
|
||||||
|
pub settings_rev: AtomicI64,
|
||||||
|
pub metrics: Metrics,
|
||||||
|
pub auth: AuthGuard,
|
||||||
|
pub pool: Arc<ImapPool>,
|
||||||
|
pub logs: Arc<LogRing>,
|
||||||
|
pub started: Instant,
|
||||||
|
pub started_ts: i64,
|
||||||
|
http: Mutex<(i64, Option<reqwest::Client>, Option<reqwest::Client>)>,
|
||||||
|
/// Random per-process key for HMACs (credential cache keys).
|
||||||
|
pub secret: [u8; 32],
|
||||||
|
loops: Mutex<std::collections::HashMap<String, (u32, i64)>>,
|
||||||
|
pub shutdown: tokio_util::sync::CancellationToken,
|
||||||
|
/// Collection ids a device still uses but that do not exist here (e.g. left over from
|
||||||
|
/// Z-Push). They are sent as FolderHierarchy Delete on the next FolderSync.
|
||||||
|
stale: Mutex<std::collections::HashMap<i64, std::collections::BTreeSet<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub async fn new(boot: Boot, logs: Arc<LogRing>) -> anyhow::Result<Arc<App>> {
|
||||||
|
let db = db::open(&boot.data_dir).await?;
|
||||||
|
let (settings, rev) = db::load_settings(&db).await?;
|
||||||
|
if let Err(e) = settings.validate() {
|
||||||
|
tracing::warn!("stored settings do not validate ({e}); running with them anyway");
|
||||||
|
}
|
||||||
|
logs.set_cap(settings.logging.ring_size);
|
||||||
|
let mut secret = [0u8; 32];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut secret);
|
||||||
|
Ok(Arc::new(App {
|
||||||
|
db,
|
||||||
|
boot,
|
||||||
|
settings: ArcSwap::from_pointee(settings),
|
||||||
|
settings_rev: AtomicI64::new(rev),
|
||||||
|
metrics: Metrics::new(),
|
||||||
|
auth: AuthGuard::default(),
|
||||||
|
pool: Arc::new(ImapPool::new(secret)),
|
||||||
|
logs,
|
||||||
|
started: Instant::now(),
|
||||||
|
started_ts: crate::util::now(),
|
||||||
|
http: Mutex::new((-1, None, None)),
|
||||||
|
secret,
|
||||||
|
loops: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
shutdown: tokio_util::sync::CancellationToken::new(),
|
||||||
|
stale: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings(&self) -> Arc<Settings> {
|
||||||
|
self.settings.load_full()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_settings(&self, s: Settings) -> anyhow::Result<i64> {
|
||||||
|
let rev = db::save_settings(&self.db, &s).await?;
|
||||||
|
self.logs.set_cap(s.logging.ring_size);
|
||||||
|
self.settings.store(Arc::new(s));
|
||||||
|
self.settings_rev.store(rev, Ordering::SeqCst);
|
||||||
|
// backend endpoints may have changed
|
||||||
|
self.pool.clear();
|
||||||
|
self.auth.clear_cache();
|
||||||
|
Ok(rev)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_clients(&self, s: &Settings) -> (Option<reqwest::Client>, Option<reqwest::Client>) {
|
||||||
|
let rev = self.settings_rev.load(Ordering::SeqCst);
|
||||||
|
let mut g = self.http.lock().unwrap();
|
||||||
|
if g.0 != rev {
|
||||||
|
let cal = s
|
||||||
|
.caldav
|
||||||
|
.enabled
|
||||||
|
.then(|| DavClient::build_http(s.caldav.verify_tls, s.caldav.timeout_secs).ok())
|
||||||
|
.flatten();
|
||||||
|
let card = s
|
||||||
|
.carddav
|
||||||
|
.enabled
|
||||||
|
.then(|| DavClient::build_http(s.carddav.verify_tls, s.carddav.timeout_secs).ok())
|
||||||
|
.flatten();
|
||||||
|
*g = (rev, cal, card);
|
||||||
|
}
|
||||||
|
(g.1.clone(), g.2.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stale_add(&self, device: i64, collection: &str) {
|
||||||
|
if collection.is_empty() || collection.len() > 64 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut g = self.stale.lock().unwrap();
|
||||||
|
let set = g.entry(device).or_default();
|
||||||
|
if set.len() < 500 {
|
||||||
|
set.insert(collection.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stale_take(&self, device: i64) -> Vec<String> {
|
||||||
|
self.stale
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.remove(&device)
|
||||||
|
.map(|s| s.into_iter().collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count how often a (device, collection, old sync key) was retried within the last hour.
|
||||||
|
pub fn loop_hit(&self, key: &str) -> u32 {
|
||||||
|
let now = crate::util::now();
|
||||||
|
let mut g = self.loops.lock().unwrap();
|
||||||
|
g.retain(|_, (_, t)| *t > now - 3600);
|
||||||
|
let e = g.entry(key.to_string()).or_insert((0, now));
|
||||||
|
e.0 += 1;
|
||||||
|
e.1 = now;
|
||||||
|
e.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session(&self, login: &str, password: &str) -> Session {
|
||||||
|
let s = self.settings();
|
||||||
|
let (cal, card) = self.http_clients(&s);
|
||||||
|
Session::new(s, self.pool.clone(), cal, card, login, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//! Autodiscover for mobile devices (MobileSync schema) - replaces z-push/autodiscover.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{ConnectInfo, State};
|
||||||
|
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::backend::AuthResult;
|
||||||
|
use crate::eas::{basic_auth, client_ip, normalize_login, user_allowed};
|
||||||
|
use crate::metrics::Event;
|
||||||
|
use crate::util::{now, xml_escape};
|
||||||
|
use crate::xmltree;
|
||||||
|
|
||||||
|
const MOBILESYNC: &str =
|
||||||
|
"http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006";
|
||||||
|
|
||||||
|
fn xml_response(status: StatusCode, body: String) -> Response {
|
||||||
|
let mut r = (status, body).into_response();
|
||||||
|
r.headers_mut().insert(
|
||||||
|
"Content-Type",
|
||||||
|
HeaderValue::from_static("text/xml; charset=utf-8"),
|
||||||
|
);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_xml(code: u32, msg: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
|
||||||
|
<Response>
|
||||||
|
<Error Time="{}" Id="1">
|
||||||
|
<ErrorCode>{code}</ErrorCode>
|
||||||
|
<Message>{}</Message>
|
||||||
|
<DebugData />
|
||||||
|
</Error>
|
||||||
|
</Response>
|
||||||
|
</Autodiscover>"#,
|
||||||
|
chrono::Utc::now().format("%H:%M:%S%.6f"),
|
||||||
|
xml_escape(msg)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unauthorized() -> Response {
|
||||||
|
let mut r = (StatusCode::UNAUTHORIZED, "Access denied").into_response();
|
||||||
|
r.headers_mut().insert(
|
||||||
|
"WWW-Authenticate",
|
||||||
|
HeaderValue::from_static("Basic realm=\"ActiveSync\""),
|
||||||
|
);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_host(h: &str) -> Option<&str> {
|
||||||
|
(!h.is_empty()
|
||||||
|
&& h.len() < 256
|
||||||
|
&& h.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')))
|
||||||
|
.then_some(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handler(
|
||||||
|
State(app): State<Arc<App>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
method: Method,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Body,
|
||||||
|
) -> Response {
|
||||||
|
let s = app.settings();
|
||||||
|
let ip = client_ip(peer, &headers, &s).to_string();
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
app.metrics
|
||||||
|
.c
|
||||||
|
.autodiscover
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
if !s.autodiscover.enabled {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
if method != Method::POST && method != Method::GET {
|
||||||
|
return StatusCode::METHOD_NOT_ALLOWED.into_response();
|
||||||
|
}
|
||||||
|
let body = match http_body_util::Limited::new(body, 64 * 1024)
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(b) => b.to_bytes(),
|
||||||
|
Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
|
||||||
|
};
|
||||||
|
// requested address + schema
|
||||||
|
let (mut email, schema) = if body.is_empty() {
|
||||||
|
(String::new(), MOBILESYNC.to_string())
|
||||||
|
} else {
|
||||||
|
match xmltree::parse(&body) {
|
||||||
|
Ok(x) => (
|
||||||
|
x.text_of("EMailAddress").unwrap_or_default(),
|
||||||
|
x.text_of("AcceptableResponseSchema").unwrap_or_default(),
|
||||||
|
),
|
||||||
|
Err(_) => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if schema != MOBILESYNC {
|
||||||
|
tracing::info!(ip = %ip, schema = %schema, "autodiscover: unsupported response schema");
|
||||||
|
return xml_response(StatusCode::OK, error_xml(600, "Invalid Request"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// authentication
|
||||||
|
let creds = basic_auth(&headers, &s);
|
||||||
|
let login = match (&creds, s.autodiscover.require_auth) {
|
||||||
|
(None, true) => return unauthorized(),
|
||||||
|
(Some((l, _)), _) => l.clone(),
|
||||||
|
(None, false) => match normalize_login(&email, &s) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let ip_key = format!("ip:{ip}");
|
||||||
|
let user_key = format!("user:{login}");
|
||||||
|
if app.auth.locked_until(&ip_key).is_some() || app.auth.locked_until(&user_key).is_some() {
|
||||||
|
return (StatusCode::TOO_MANY_REQUESTS, "Too many failed logins").into_response();
|
||||||
|
}
|
||||||
|
if !user_allowed(&login, &s) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
if let Some((l, p)) = &creds {
|
||||||
|
let key = app.pool.key(l, p);
|
||||||
|
if !app.auth.is_cached(&key) {
|
||||||
|
let mut sess = app.session(l, p);
|
||||||
|
match sess.authenticate().await {
|
||||||
|
Ok(AuthResult::Ok) => {
|
||||||
|
app.auth.success(&user_key);
|
||||||
|
app.auth.cache_ok(key, s.security.auth_cache_secs);
|
||||||
|
sess.close().await;
|
||||||
|
}
|
||||||
|
Ok(AuthResult::Denied) => {
|
||||||
|
app.metrics.auth_failure();
|
||||||
|
if s.security.log_auth_failures {
|
||||||
|
tracing::warn!(target: "active_push::auth", "authentication failed user={l} ip={ip} service=autodiscover");
|
||||||
|
}
|
||||||
|
let (m, w, lo) = (
|
||||||
|
s.security.auth_fail_max,
|
||||||
|
s.security.auth_fail_window_secs,
|
||||||
|
s.security.lockout_secs,
|
||||||
|
);
|
||||||
|
app.auth.fail(&ip_key, m * 3, w, lo);
|
||||||
|
app.auth.fail(&user_key, m, w, lo);
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("autodiscover: backend unavailable: {e:#}");
|
||||||
|
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if email.is_empty() || !email.contains('@') {
|
||||||
|
email = crate::backend::primary_email(&s, &login);
|
||||||
|
}
|
||||||
|
let url = if !s.autodiscover.server_url.is_empty() {
|
||||||
|
s.autodiscover.server_url.clone()
|
||||||
|
} else {
|
||||||
|
let host = headers
|
||||||
|
.get("x-forwarded-host")
|
||||||
|
.or_else(|| headers.get("host"))
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.and_then(safe_host)
|
||||||
|
.unwrap_or("localhost");
|
||||||
|
format!("https://{host}/Microsoft-Server-ActiveSync")
|
||||||
|
};
|
||||||
|
let display = email.clone();
|
||||||
|
let xml = format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Autodiscover xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
|
||||||
|
<Response xmlns="{MOBILESYNC}">
|
||||||
|
<Culture>en:en</Culture>
|
||||||
|
<User>
|
||||||
|
<DisplayName>{}</DisplayName>
|
||||||
|
<EMailAddress>{}</EMailAddress>
|
||||||
|
</User>
|
||||||
|
<Action>
|
||||||
|
<Settings>
|
||||||
|
<Server>
|
||||||
|
<Type>MobileSync</Type>
|
||||||
|
<Url>{}</Url>
|
||||||
|
<Name>{}</Name>
|
||||||
|
</Server>
|
||||||
|
</Settings>
|
||||||
|
</Action>
|
||||||
|
</Response>
|
||||||
|
</Autodiscover>"#,
|
||||||
|
xml_escape(&display),
|
||||||
|
xml_escape(&email),
|
||||||
|
xml_escape(&url),
|
||||||
|
xml_escape(&url)
|
||||||
|
);
|
||||||
|
tracing::info!(user = %login, ip = %ip, "autodiscover answered");
|
||||||
|
app.metrics.record(Event {
|
||||||
|
ts: now(),
|
||||||
|
user: login,
|
||||||
|
device_id: String::new(),
|
||||||
|
device_type: "Autodiscover".into(),
|
||||||
|
command: "Autodiscover".into(),
|
||||||
|
ip,
|
||||||
|
http: 200,
|
||||||
|
status: "ok".into(),
|
||||||
|
ms: started.elapsed().as_millis() as u64,
|
||||||
|
bytes_in: body.len() as u64,
|
||||||
|
bytes_out: xml.len() as u64,
|
||||||
|
items_out: 0,
|
||||||
|
items_in: 0,
|
||||||
|
});
|
||||||
|
xml_response(StatusCode::OK, xml)
|
||||||
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
//! Minimal CalDAV / CardDAV client (RFC 4791 / RFC 6352).
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
|
||||||
|
use reqwest::{Client, Method, StatusCode};
|
||||||
|
|
||||||
|
use crate::util::xml_escape;
|
||||||
|
use crate::xmltree;
|
||||||
|
|
||||||
|
const PATH_SEG: &AsciiSet = &CONTROLS
|
||||||
|
.add(b' ')
|
||||||
|
.add(b'"')
|
||||||
|
.add(b'#')
|
||||||
|
.add(b'<')
|
||||||
|
.add(b'>')
|
||||||
|
.add(b'?')
|
||||||
|
.add(b'`')
|
||||||
|
.add(b'{')
|
||||||
|
.add(b'}')
|
||||||
|
.add(b'/')
|
||||||
|
.add(b'%')
|
||||||
|
.add(b'\\');
|
||||||
|
|
||||||
|
pub fn encode_segment(s: &str) -> String {
|
||||||
|
utf8_percent_encode(s, PATH_SEG).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DavKind {
|
||||||
|
Calendar,
|
||||||
|
AddressBook,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Collection {
|
||||||
|
pub href: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub ctag: String,
|
||||||
|
pub sync_token: String,
|
||||||
|
pub supports_events: bool,
|
||||||
|
pub supports_todos: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Resource {
|
||||||
|
pub href: String,
|
||||||
|
pub etag: String,
|
||||||
|
pub data: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DavClient {
|
||||||
|
http: Client,
|
||||||
|
base: url::Url,
|
||||||
|
user: String,
|
||||||
|
pass: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DavClient {
|
||||||
|
pub fn build_http(verify: bool, timeout_secs: u64) -> Result<Client> {
|
||||||
|
let mut b = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(timeout_secs.max(5)))
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.redirect(reqwest::redirect::Policy::limited(3))
|
||||||
|
.user_agent(concat!("ActivePush/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.pool_idle_timeout(Duration::from_secs(60));
|
||||||
|
if !verify {
|
||||||
|
b = b.danger_accept_invalid_certs(true);
|
||||||
|
}
|
||||||
|
Ok(b.build()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(http: Client, base_url: &str, user: &str, pass: &str) -> Result<Self> {
|
||||||
|
let base = url::Url::parse(base_url).context("invalid DAV base URL")?;
|
||||||
|
Ok(DavClient {
|
||||||
|
http,
|
||||||
|
base,
|
||||||
|
user: user.to_string(),
|
||||||
|
pass: pass.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, href: &str) -> Result<url::Url> {
|
||||||
|
let u = self.base.join(href)?;
|
||||||
|
// never follow hrefs to other hosts (SSRF protection)
|
||||||
|
if u.host_str() != self.base.host_str()
|
||||||
|
|| u.port_or_known_default() != self.base.port_or_known_default()
|
||||||
|
{
|
||||||
|
bail!("DAV href points to a different host");
|
||||||
|
}
|
||||||
|
Ok(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
href: &str,
|
||||||
|
depth: Option<&str>,
|
||||||
|
body: Option<String>,
|
||||||
|
content_type: &str,
|
||||||
|
extra: &[(&str, String)],
|
||||||
|
) -> Result<(StatusCode, reqwest::header::HeaderMap, String)> {
|
||||||
|
let m = Method::from_bytes(method.as_bytes())?;
|
||||||
|
let mut rb = self
|
||||||
|
.http
|
||||||
|
.request(m, self.url(href)?)
|
||||||
|
.basic_auth(&self.user, Some(&self.pass));
|
||||||
|
if let Some(d) = depth {
|
||||||
|
rb = rb.header("Depth", d);
|
||||||
|
}
|
||||||
|
for (k, v) in extra {
|
||||||
|
rb = rb.header(*k, v);
|
||||||
|
}
|
||||||
|
if let Some(b) = body {
|
||||||
|
rb = rb.header("Content-Type", content_type).body(b);
|
||||||
|
}
|
||||||
|
let resp = rb
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("DAV {method} {href}"))?;
|
||||||
|
let status = resp.status();
|
||||||
|
let headers = resp.headers().clone();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
if status == StatusCode::UNAUTHORIZED {
|
||||||
|
bail!("DAV authentication failed");
|
||||||
|
}
|
||||||
|
Ok((status, headers, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check(&self, home: &str) -> Result<()> {
|
||||||
|
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>"#;
|
||||||
|
let (st, _, _) = self
|
||||||
|
.request(
|
||||||
|
"PROPFIND",
|
||||||
|
home,
|
||||||
|
Some("0"),
|
||||||
|
Some(body.into()),
|
||||||
|
"application/xml; charset=utf-8",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !st.is_success() && st.as_u16() != 207 {
|
||||||
|
bail!("DAV home {home} returned {st}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover collections below a home path.
|
||||||
|
pub async fn collections(&self, home: &str, kind: DavKind) -> Result<Vec<Collection>> {
|
||||||
|
let body = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:prop><d:resourcetype/><d:displayname/><cs:getctag/><d:sync-token/><c:supported-calendar-component-set/></d:prop>
|
||||||
|
</d:propfind>"#;
|
||||||
|
let (st, _, text) = self
|
||||||
|
.request(
|
||||||
|
"PROPFIND",
|
||||||
|
home,
|
||||||
|
Some("1"),
|
||||||
|
Some(body.into()),
|
||||||
|
"application/xml; charset=utf-8",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if st.as_u16() != 207 {
|
||||||
|
bail!("PROPFIND {home} returned {st}");
|
||||||
|
}
|
||||||
|
let doc = xmltree::parse(text.as_bytes())?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for resp in doc.children_named("response") {
|
||||||
|
let href = resp.text_of("href").unwrap_or_default();
|
||||||
|
let Some(rt) = resp.find("resourcetype") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let is_cal = rt.child("calendar").is_some();
|
||||||
|
let is_book = rt.child("addressbook").is_some();
|
||||||
|
if (kind == DavKind::Calendar && !is_cal) || (kind == DavKind::AddressBook && !is_book)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (mut ev, mut todo) = (true, true);
|
||||||
|
if let Some(set) = resp.find("supported-calendar-component-set") {
|
||||||
|
let comps: Vec<String> = set
|
||||||
|
.children_named("comp")
|
||||||
|
.filter_map(|c| c.attr("name").map(|n| n.to_ascii_uppercase()))
|
||||||
|
.collect();
|
||||||
|
if !comps.is_empty() {
|
||||||
|
ev = comps.iter().any(|c| c == "VEVENT");
|
||||||
|
todo = comps.iter().any(|c| c == "VTODO");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name = resp
|
||||||
|
.text_of("displayname")
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| last_segment(&href));
|
||||||
|
out.push(Collection {
|
||||||
|
href: normalize_dir(&href),
|
||||||
|
display_name: name,
|
||||||
|
ctag: resp.text_of("getctag").unwrap_or_default(),
|
||||||
|
sync_token: resp.text_of("sync-token").unwrap_or_default(),
|
||||||
|
supports_events: ev,
|
||||||
|
supports_todos: todo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap change marker for a collection (ctag / sync-token / etag list hash fallback).
|
||||||
|
pub async fn ctag(&self, href: &str) -> Result<String> {
|
||||||
|
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><cs:getctag/><d:sync-token/></d:prop></d:propfind>"#;
|
||||||
|
let (st, _, text) = self
|
||||||
|
.request(
|
||||||
|
"PROPFIND",
|
||||||
|
href,
|
||||||
|
Some("0"),
|
||||||
|
Some(body.into()),
|
||||||
|
"application/xml; charset=utf-8",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if st.as_u16() != 207 {
|
||||||
|
bail!("PROPFIND {href} returned {st}");
|
||||||
|
}
|
||||||
|
let doc = xmltree::parse(text.as_bytes())?;
|
||||||
|
let tag = doc.text_of("getctag").filter(|s| !s.is_empty());
|
||||||
|
let tok = doc.text_of("sync-token").filter(|s| !s.is_empty());
|
||||||
|
Ok(tag.or(tok).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List resources (href + etag). For calendars a component filter and optional
|
||||||
|
/// time-range start (UTC, compact format) are applied server side.
|
||||||
|
pub async fn list(
|
||||||
|
&self,
|
||||||
|
href: &str,
|
||||||
|
comp: Option<&str>,
|
||||||
|
since: Option<&str>,
|
||||||
|
) -> Result<Vec<Resource>> {
|
||||||
|
let text = if let Some(comp) = comp {
|
||||||
|
let range = since
|
||||||
|
.map(|s| format!("<c:time-range start=\"{}\"/>", xml_escape(s)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let body = format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:prop><d:getetag/></d:prop>
|
||||||
|
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="{comp}">{range}</c:comp-filter></c:comp-filter></c:filter>
|
||||||
|
</c:calendar-query>"#
|
||||||
|
);
|
||||||
|
let (st, _, text) = self
|
||||||
|
.request(
|
||||||
|
"REPORT",
|
||||||
|
href,
|
||||||
|
Some("1"),
|
||||||
|
Some(body),
|
||||||
|
"application/xml; charset=utf-8",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if st.as_u16() != 207 {
|
||||||
|
bail!("calendar-query {href} returned {st}");
|
||||||
|
}
|
||||||
|
text
|
||||||
|
} else {
|
||||||
|
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
|
||||||
|
let (st, _, text) = self
|
||||||
|
.request(
|
||||||
|
"PROPFIND",
|
||||||
|
href,
|
||||||
|
Some("1"),
|
||||||
|
Some(body.into()),
|
||||||
|
"application/xml; charset=utf-8",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if st.as_u16() != 207 {
|
||||||
|
bail!("PROPFIND {href} returned {st}");
|
||||||
|
}
|
||||||
|
text
|
||||||
|
};
|
||||||
|
let doc = xmltree::parse(text.as_bytes())?;
|
||||||
|
let base = normalize_dir(href);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for resp in doc.children_named("response") {
|
||||||
|
let h = resp.text_of("href").unwrap_or_default();
|
||||||
|
if normalize_dir(&h) == base || h.ends_with('/') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if resp
|
||||||
|
.find("resourcetype")
|
||||||
|
.map(|r| !r.children.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ok = resp.children_named("propstat").any(|p| {
|
||||||
|
p.text_of("status")
|
||||||
|
.map(|s| s.contains(" 200"))
|
||||||
|
.unwrap_or(true)
|
||||||
|
&& p.find("getetag").is_some()
|
||||||
|
});
|
||||||
|
if !ok {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(Resource {
|
||||||
|
href: h,
|
||||||
|
etag: resp.text_of("getetag").unwrap_or_default(),
|
||||||
|
data: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch several resources at once (calendar-multiget / addressbook-multiget).
|
||||||
|
pub async fn multiget(
|
||||||
|
&self,
|
||||||
|
collection: &str,
|
||||||
|
hrefs: &[String],
|
||||||
|
kind: DavKind,
|
||||||
|
) -> Result<Vec<Resource>> {
|
||||||
|
if hrefs.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let mut hs = String::new();
|
||||||
|
for h in hrefs {
|
||||||
|
hs.push_str(&format!("<d:href>{}</d:href>", xml_escape(h)));
|
||||||
|
}
|
||||||
|
let body = match kind {
|
||||||
|
DavKind::Calendar => format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?><c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop>{hs}</c:calendar-multiget>"#
|
||||||
|
),
|
||||||
|
DavKind::AddressBook => format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?><c:addressbook-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><c:address-data/></d:prop>{hs}</c:addressbook-multiget>"#
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let (st, _, text) = self
|
||||||
|
.request(
|
||||||
|
"REPORT",
|
||||||
|
collection,
|
||||||
|
Some("1"),
|
||||||
|
Some(body),
|
||||||
|
"application/xml; charset=utf-8",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if st.as_u16() != 207 {
|
||||||
|
// fallback: individual GETs
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for h in hrefs {
|
||||||
|
if let Ok(r) = self.get(h).await {
|
||||||
|
out.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
let doc = xmltree::parse(text.as_bytes())?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for resp in doc.children_named("response") {
|
||||||
|
let data = resp
|
||||||
|
.text_of("calendar-data")
|
||||||
|
.or_else(|| resp.text_of("address-data"))
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
if data.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(Resource {
|
||||||
|
href: resp.text_of("href").unwrap_or_default(),
|
||||||
|
etag: resp.text_of("getetag").unwrap_or_default(),
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, href: &str) -> Result<Resource> {
|
||||||
|
let (st, headers, text) = self.request("GET", href, None, None, "", &[]).await?;
|
||||||
|
if st == StatusCode::NOT_FOUND {
|
||||||
|
bail!("not found");
|
||||||
|
}
|
||||||
|
if !st.is_success() {
|
||||||
|
bail!("GET {href} returned {st}");
|
||||||
|
}
|
||||||
|
Ok(Resource {
|
||||||
|
href: href.to_string(),
|
||||||
|
etag: headers
|
||||||
|
.get("etag")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
data: Some(text),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT a resource. `if_match` = Some(etag) for updates, None for create (If-None-Match: *).
|
||||||
|
pub async fn put(
|
||||||
|
&self,
|
||||||
|
href: &str,
|
||||||
|
body: String,
|
||||||
|
content_type: &str,
|
||||||
|
if_match: Option<&str>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let extra = match if_match {
|
||||||
|
Some(e) if !e.is_empty() => vec![("If-Match", e.to_string())],
|
||||||
|
Some(_) => vec![],
|
||||||
|
None => vec![("If-None-Match", "*".to_string())],
|
||||||
|
};
|
||||||
|
let (st, headers, text) = self
|
||||||
|
.request("PUT", href, None, Some(body), content_type, &extra)
|
||||||
|
.await?;
|
||||||
|
if st == StatusCode::PRECONDITION_FAILED {
|
||||||
|
bail!("conflict: resource changed on server");
|
||||||
|
}
|
||||||
|
if !st.is_success() {
|
||||||
|
bail!(
|
||||||
|
"PUT {href} returned {st}: {}",
|
||||||
|
crate::util::truncate_bytes(&text, 200)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let etag = headers
|
||||||
|
.get("etag")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
match etag {
|
||||||
|
Some(e) => Ok(e),
|
||||||
|
None => Ok(self.get(href).await.map(|r| r.etag).unwrap_or_default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, href: &str, if_match: Option<&str>) -> Result<()> {
|
||||||
|
let extra = if_match
|
||||||
|
.filter(|e| !e.is_empty())
|
||||||
|
.map(|e| vec![("If-Match", e.to_string())])
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (st, _, _) = self.request("DELETE", href, None, None, "", &extra).await?;
|
||||||
|
if st.is_success() || st == StatusCode::NOT_FOUND {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("DELETE {href} returned {st}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn make_collection(
|
||||||
|
&self,
|
||||||
|
href: &str,
|
||||||
|
name: &str,
|
||||||
|
kind: DavKind,
|
||||||
|
todo: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let n = xml_escape(name);
|
||||||
|
let (method, body) = match kind {
|
||||||
|
DavKind::Calendar => (
|
||||||
|
"MKCALENDAR",
|
||||||
|
format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?><c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:set><d:prop><d:displayname>{n}</d:displayname><c:supported-calendar-component-set><c:comp name="{}"/></c:supported-calendar-component-set></d:prop></d:set></c:mkcalendar>"#,
|
||||||
|
if todo { "VTODO" } else { "VEVENT" }
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DavKind::AddressBook => (
|
||||||
|
"MKCOL",
|
||||||
|
format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?><d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:set><d:prop><d:resourcetype><d:collection/><c:addressbook/></d:resourcetype><d:displayname>{n}</d:displayname></d:prop></d:set></d:mkcol>"#
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let (st, _, text) = self
|
||||||
|
.request(
|
||||||
|
method,
|
||||||
|
href,
|
||||||
|
None,
|
||||||
|
Some(body),
|
||||||
|
"application/xml; charset=utf-8",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !st.is_success() {
|
||||||
|
bail!(
|
||||||
|
"{method} {href} returned {st}: {}",
|
||||||
|
crate::util::truncate_bytes(&text, 200)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rename(&self, href: &str, name: &str) -> Result<()> {
|
||||||
|
let body = format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?><d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>{}</d:displayname></d:prop></d:set></d:propertyupdate>"#,
|
||||||
|
xml_escape(name)
|
||||||
|
);
|
||||||
|
let (st, _, _) = self
|
||||||
|
.request(
|
||||||
|
"PROPPATCH",
|
||||||
|
href,
|
||||||
|
None,
|
||||||
|
Some(body),
|
||||||
|
"application/xml; charset=utf-8",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !(st.is_success() || st.as_u16() == 207) {
|
||||||
|
bail!("PROPPATCH {href} returned {st}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// addressbook-query text match on FN / EMAIL (GAL search).
|
||||||
|
pub async fn search_cards(&self, href: &str, text: &str) -> Result<Vec<Resource>> {
|
||||||
|
let t = xml_escape(text);
|
||||||
|
let body = format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
|
||||||
|
<d:prop><d:getetag/><c:address-data/></d:prop>
|
||||||
|
<c:filter test="anyof">
|
||||||
|
<c:prop-filter name="FN"><c:text-match collation="i;unicode-casemap" match-type="contains">{t}</c:text-match></c:prop-filter>
|
||||||
|
<c:prop-filter name="EMAIL"><c:text-match collation="i;unicode-casemap" match-type="contains">{t}</c:text-match></c:prop-filter>
|
||||||
|
<c:prop-filter name="N"><c:text-match collation="i;unicode-casemap" match-type="contains">{t}</c:text-match></c:prop-filter>
|
||||||
|
</c:filter>
|
||||||
|
</c:addressbook-query>"#
|
||||||
|
);
|
||||||
|
let (st, _, resp) = self
|
||||||
|
.request(
|
||||||
|
"REPORT",
|
||||||
|
href,
|
||||||
|
Some("1"),
|
||||||
|
Some(body),
|
||||||
|
"application/xml; charset=utf-8",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if st.as_u16() != 207 {
|
||||||
|
bail!("addressbook-query returned {st}");
|
||||||
|
}
|
||||||
|
let doc = xmltree::parse(resp.as_bytes())?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in doc.children_named("response") {
|
||||||
|
if let Some(d) = r.text_of("address-data").filter(|s| !s.is_empty()) {
|
||||||
|
out.push(Resource {
|
||||||
|
href: r.text_of("href").unwrap_or_default(),
|
||||||
|
etag: r.text_of("getetag").unwrap_or_default(),
|
||||||
|
data: Some(d),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_dir(h: &str) -> String {
|
||||||
|
let d = percent_decode_str(h).decode_utf8_lossy().into_owned();
|
||||||
|
if d.ends_with('/') {
|
||||||
|
d
|
||||||
|
} else {
|
||||||
|
format!("{d}/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_segment(h: &str) -> String {
|
||||||
|
let d = percent_decode_str(h.trim_end_matches('/'))
|
||||||
|
.decode_utf8_lossy()
|
||||||
|
.into_owned();
|
||||||
|
d.rsplit('/').next().unwrap_or("").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-encode a decoded collection path for use in URLs.
|
||||||
|
pub fn encode_path(path: &str) -> String {
|
||||||
|
path.split('/')
|
||||||
|
.map(encode_segment)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn paths() {
|
||||||
|
assert_eq!(normalize_dir("/a/b%20c"), "/a/b c/");
|
||||||
|
assert_eq!(last_segment("/a/b/x%40y.ics"), "x@y.ics");
|
||||||
|
assert_eq!(encode_path("/a b/c@d/"), "/a%20b/c@d/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
//! Generic iCalendar (RFC 5545) / vCard (RFC 6350, 2.1/3.0 tolerant) content-line model.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Property {
|
||||||
|
pub name: String,
|
||||||
|
pub params: Vec<(String, String)>,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property {
|
||||||
|
pub fn new(name: &str, value: impl Into<String>) -> Self {
|
||||||
|
Property {
|
||||||
|
name: name.to_ascii_uppercase(),
|
||||||
|
params: vec![],
|
||||||
|
value: value.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text(name: &str, value: &str) -> Self {
|
||||||
|
Property::new(name, escape_text(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_param(mut self, k: &str, v: &str) -> Self {
|
||||||
|
self.params.push((k.to_ascii_uppercase(), v.to_string()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn param(&self, k: &str) -> Option<&str> {
|
||||||
|
self.params
|
||||||
|
.iter()
|
||||||
|
.find(|(n, _)| n.eq_ignore_ascii_case(k))
|
||||||
|
.map(|(_, v)| v.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All TYPE values (handles `TYPE=a,b`, repeated TYPE and vCard 2.1 bare params).
|
||||||
|
pub fn types(&self) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (k, v) in &self.params {
|
||||||
|
if k.eq_ignore_ascii_case("TYPE") {
|
||||||
|
out.extend(v.split(',').map(|s| s.trim().to_ascii_lowercase()));
|
||||||
|
} else if v.is_empty() {
|
||||||
|
out.push(k.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text_value(&self) -> String {
|
||||||
|
unescape_text(&self.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub struct Component {
|
||||||
|
pub name: String,
|
||||||
|
pub props: Vec<Property>,
|
||||||
|
pub children: Vec<Component>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component {
|
||||||
|
pub fn new(name: &str) -> Self {
|
||||||
|
Component {
|
||||||
|
name: name.to_ascii_uppercase(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prop(&self, name: &str) -> Option<&Property> {
|
||||||
|
self.props
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name.eq_ignore_ascii_case(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn props_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Property> + 'a {
|
||||||
|
self.props
|
||||||
|
.iter()
|
||||||
|
.filter(move |p| p.name.eq_ignore_ascii_case(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text(&self, name: &str) -> Option<String> {
|
||||||
|
self.prop(name)
|
||||||
|
.map(|p| p.text_value())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, name: &str) {
|
||||||
|
self.props.retain(|p| !p.name.eq_ignore_ascii_case(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&mut self, p: Property) {
|
||||||
|
self.remove(&p.name.clone());
|
||||||
|
self.props.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&mut self, p: Property) {
|
||||||
|
self.props.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn child(&self, name: &str) -> Option<&Component> {
|
||||||
|
self.children
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.name.eq_ignore_ascii_case(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Component> + 'a {
|
||||||
|
self.children
|
||||||
|
.iter()
|
||||||
|
.filter(move |c| c.name.eq_ignore_ascii_case(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serialize(&self) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
self.write(&mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, out: &mut String) {
|
||||||
|
fold_line(out, &format!("BEGIN:{}", self.name));
|
||||||
|
for p in &self.props {
|
||||||
|
let mut line = p.name.clone();
|
||||||
|
for (k, v) in &p.params {
|
||||||
|
line.push(';');
|
||||||
|
line.push_str(k);
|
||||||
|
line.push('=');
|
||||||
|
if v.contains([':', ';', ',']) && !v.starts_with('"') {
|
||||||
|
line.push('"');
|
||||||
|
line.push_str(&v.replace('"', "'"));
|
||||||
|
line.push('"');
|
||||||
|
} else {
|
||||||
|
line.push_str(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line.push(':');
|
||||||
|
line.push_str(&p.value);
|
||||||
|
fold_line(out, &line);
|
||||||
|
}
|
||||||
|
for c in &self.children {
|
||||||
|
c.write(out);
|
||||||
|
}
|
||||||
|
fold_line(out, &format!("END:{}", self.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_line(out: &mut String, line: &str) {
|
||||||
|
// fold at 75 octets on char boundaries
|
||||||
|
let line = line.replace(['\r', '\n'], "");
|
||||||
|
let mut count = 0;
|
||||||
|
for c in line.chars() {
|
||||||
|
let l = c.len_utf8();
|
||||||
|
if count + l > 75 {
|
||||||
|
out.push_str("\r\n ");
|
||||||
|
count = 1;
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
count += l;
|
||||||
|
}
|
||||||
|
out.push_str("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an iCalendar/vCard stream. Returns the top-level components.
|
||||||
|
pub fn parse(input: &str) -> Vec<Component> {
|
||||||
|
// unfold
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
for raw in input.split('\n') {
|
||||||
|
let raw = raw.strip_suffix('\r').unwrap_or(raw);
|
||||||
|
if (raw.starts_with(' ') || raw.starts_with('\t')) && !lines.is_empty() {
|
||||||
|
lines.last_mut().unwrap().push_str(&raw[1..]);
|
||||||
|
} else if !raw.is_empty() {
|
||||||
|
lines.push(raw.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// vCard 2.1 quoted-printable soft line breaks
|
||||||
|
let mut merged: Vec<String> = Vec::new();
|
||||||
|
for l in lines {
|
||||||
|
if let Some(last) = merged.last_mut() {
|
||||||
|
if last.to_ascii_uppercase().contains("QUOTED-PRINTABLE") && last.ends_with('=') {
|
||||||
|
last.pop();
|
||||||
|
last.push_str(&l);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.push(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stack: Vec<Component> = vec![Component::new("ROOT")];
|
||||||
|
for line in merged {
|
||||||
|
let Some(p) = parse_line(&line) else { continue };
|
||||||
|
if p.name == "BEGIN" {
|
||||||
|
if stack.len() > 32 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
stack.push(Component::new(&p.value));
|
||||||
|
} else if p.name == "END" {
|
||||||
|
if stack.len() > 1 {
|
||||||
|
let c = stack.pop().unwrap();
|
||||||
|
stack.last_mut().unwrap().children.push(c);
|
||||||
|
}
|
||||||
|
} else if let Some(top) = stack.last_mut() {
|
||||||
|
if top.props.len() < 10_000 {
|
||||||
|
top.props.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while stack.len() > 1 {
|
||||||
|
let c = stack.pop().unwrap();
|
||||||
|
stack.last_mut().unwrap().children.push(c);
|
||||||
|
}
|
||||||
|
stack.pop().map(|r| r.children).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_line(line: &str) -> Option<Property> {
|
||||||
|
// find the first ':' not inside quotes
|
||||||
|
let mut in_q = false;
|
||||||
|
let mut colon = None;
|
||||||
|
for (i, c) in line.char_indices() {
|
||||||
|
match c {
|
||||||
|
'"' => in_q = !in_q,
|
||||||
|
':' if !in_q => {
|
||||||
|
colon = Some(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let colon = colon?;
|
||||||
|
let head = &line[..colon];
|
||||||
|
let mut value = line[colon + 1..].to_string();
|
||||||
|
let mut parts = split_unquoted(head, ';').into_iter();
|
||||||
|
let mut name = parts.next()?.trim().to_ascii_uppercase();
|
||||||
|
// strip vCard group prefix (item1.EMAIL)
|
||||||
|
if let Some((_, n)) = name.split_once('.') {
|
||||||
|
name = n.to_string();
|
||||||
|
}
|
||||||
|
let mut params = Vec::new();
|
||||||
|
for p in parts {
|
||||||
|
match p.split_once('=') {
|
||||||
|
Some((k, v)) => params.push((
|
||||||
|
k.trim().to_ascii_uppercase(),
|
||||||
|
v.trim().trim_matches('"').to_string(),
|
||||||
|
)),
|
||||||
|
None => params.push((p.trim().to_ascii_uppercase(), String::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let qp = params.iter().any(|(k, v)| {
|
||||||
|
(k == "ENCODING" && v.eq_ignore_ascii_case("QUOTED-PRINTABLE")) || k == "QUOTED-PRINTABLE"
|
||||||
|
});
|
||||||
|
if qp {
|
||||||
|
value = decode_qp(&value);
|
||||||
|
params.retain(|(k, v)| {
|
||||||
|
!(k == "ENCODING" && v.eq_ignore_ascii_case("QUOTED-PRINTABLE"))
|
||||||
|
&& k != "QUOTED-PRINTABLE"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(Property {
|
||||||
|
name,
|
||||||
|
params,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_unquoted(s: &str, sep: char) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut in_q = false;
|
||||||
|
for c in s.chars() {
|
||||||
|
if c == '"' {
|
||||||
|
in_q = !in_q;
|
||||||
|
}
|
||||||
|
if c == sep && !in_q {
|
||||||
|
out.push(std::mem::take(&mut cur));
|
||||||
|
} else {
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(cur);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_qp(s: &str) -> String {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(b.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < b.len() {
|
||||||
|
if b[i] == b'=' && i + 2 < b.len() + 1 {
|
||||||
|
if let (Some(h), Some(l)) = (b.get(i + 1), b.get(i + 2)) {
|
||||||
|
if let Ok(v) = u8::from_str_radix(&format!("{}{}", *h as char, *l as char), 16) {
|
||||||
|
out.push(v);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(b[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn escape_text(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
';' => out.push_str("\\;"),
|
||||||
|
',' => out.push_str("\\,"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => {}
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unescape_text(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut it = s.chars();
|
||||||
|
while let Some(c) = it.next() {
|
||||||
|
if c == '\\' {
|
||||||
|
match it.next() {
|
||||||
|
Some('n') | Some('N') => out.push('\n'),
|
||||||
|
Some(o) => out.push(o),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a structured value (N, ADR) on unescaped `;`.
|
||||||
|
pub fn split_structured(s: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut esc = false;
|
||||||
|
for c in s.chars() {
|
||||||
|
if esc {
|
||||||
|
cur.push('\\');
|
||||||
|
cur.push(c);
|
||||||
|
esc = false;
|
||||||
|
} else if c == '\\' {
|
||||||
|
esc = true;
|
||||||
|
} else if c == ';' {
|
||||||
|
out.push(unescape_text(&std::mem::take(&mut cur)));
|
||||||
|
} else {
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(unescape_text(&cur));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a list value (CATEGORIES) on unescaped `,`.
|
||||||
|
pub fn split_list(s: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut esc = false;
|
||||||
|
for c in s.chars() {
|
||||||
|
if esc {
|
||||||
|
cur.push('\\');
|
||||||
|
cur.push(c);
|
||||||
|
esc = false;
|
||||||
|
} else if c == '\\' {
|
||||||
|
esc = true;
|
||||||
|
} else if c == ',' {
|
||||||
|
out.push(unescape_text(&std::mem::take(&mut cur)));
|
||||||
|
} else {
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(unescape_text(&cur));
|
||||||
|
out.into_iter()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_and_roundtrip() {
|
||||||
|
let src = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:abc\r\nSUMMARY:Spotkanie\\, ważne\r\nDESCRIPTION:Linia 1\\nLinia 2 bardzo długa linia, która musi zostać zawinięta bo przekracza 75 bajtów w UTF-8 ąęś\r\nDTSTART;TZID=Europe/Warsaw:20261105T100000\r\nATTENDEE;CN=\"Kowalski, Jan\";PARTSTAT=ACCEPTED:mailto:jan@x.pl\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||||
|
let c = parse(src);
|
||||||
|
assert_eq!(c.len(), 1);
|
||||||
|
let ev = c[0].child("VEVENT").unwrap();
|
||||||
|
assert_eq!(ev.text("SUMMARY").unwrap(), "Spotkanie, ważne");
|
||||||
|
assert_eq!(
|
||||||
|
ev.prop("DTSTART").unwrap().param("TZID"),
|
||||||
|
Some("Europe/Warsaw")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ev.prop("ATTENDEE").unwrap().param("CN"),
|
||||||
|
Some("Kowalski, Jan")
|
||||||
|
);
|
||||||
|
let again = parse(&c[0].serialize());
|
||||||
|
assert_eq!(again[0], c[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vcard21() {
|
||||||
|
let src = "BEGIN:VCARD\nVERSION:2.1\nitem1.TEL;CELL;PREF:+48 600\nN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Kowalsk=C4=85;Anna;;;\nEND:VCARD\n";
|
||||||
|
let c = parse(src);
|
||||||
|
let tel = c[0].prop("TEL").unwrap();
|
||||||
|
assert!(tel.types().contains(&"cell".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
split_structured(&c[0].prop("N").unwrap().value)[0],
|
||||||
|
"Kowalską"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
//! Small MIME builder used for SmartReply / SmartForward composition.
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
pub struct Attachment {
|
||||||
|
pub name: String,
|
||||||
|
pub content_type: String,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub content_id: Option<String>,
|
||||||
|
pub inline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn boundary() -> String {
|
||||||
|
let n: u64 = rand::thread_rng().gen();
|
||||||
|
format!("=_ap_{n:016x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn b64_lines(data: &[u8]) -> String {
|
||||||
|
let enc = base64::engine::general_purpose::STANDARD.encode(data);
|
||||||
|
let mut out = String::with_capacity(enc.len() + enc.len() / 76 * 2 + 2);
|
||||||
|
for chunk in enc.as_bytes().chunks(76) {
|
||||||
|
out.push_str(std::str::from_utf8(chunk).unwrap_or(""));
|
||||||
|
out.push_str("\r\n");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 2047 encoded-word for header values / filenames when needed.
|
||||||
|
pub fn encode_word(s: &str) -> String {
|
||||||
|
if s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) && !s.contains("=?") {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"=?UTF-8?B?{}?=",
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(s.as_bytes())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote_param(s: &str) -> String {
|
||||||
|
let e = encode_word(s);
|
||||||
|
format!("\"{}\"", e.replace(['"', '\\', '\r', '\n'], "_"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a complete message from header lines (already folded/encoded), bodies and attachments.
|
||||||
|
pub fn build(
|
||||||
|
headers: &[(String, String)],
|
||||||
|
text: Option<&str>,
|
||||||
|
html: Option<&str>,
|
||||||
|
atts: &[Attachment],
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (k, v) in headers {
|
||||||
|
let v = v.replace(['\r', '\n'], " ");
|
||||||
|
out.push_str(&format!("{k}: {v}\r\n"));
|
||||||
|
}
|
||||||
|
out.push_str("MIME-Version: 1.0\r\n");
|
||||||
|
|
||||||
|
let text_part = |t: &str| {
|
||||||
|
format!(
|
||||||
|
"Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{}",
|
||||||
|
b64_lines(t.replace("\r\n", "\n").replace('\n', "\r\n").as_bytes())
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let html_part = |h: &str| {
|
||||||
|
format!(
|
||||||
|
"Content-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{}",
|
||||||
|
b64_lines(h.as_bytes())
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let body = match (text, html) {
|
||||||
|
(Some(t), Some(h)) => {
|
||||||
|
let b = boundary();
|
||||||
|
format!(
|
||||||
|
"Content-Type: multipart/alternative; boundary=\"{b}\"\r\n\r\n--{b}\r\n{}--{b}\r\n{}--{b}--\r\n",
|
||||||
|
text_part(t),
|
||||||
|
html_part(h)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(Some(t), None) => text_part(t),
|
||||||
|
(None, Some(h)) => html_part(h),
|
||||||
|
(None, None) => text_part(""),
|
||||||
|
};
|
||||||
|
if atts.is_empty() {
|
||||||
|
out.push_str(&body);
|
||||||
|
return out.into_bytes();
|
||||||
|
}
|
||||||
|
let b = boundary();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"Content-Type: multipart/mixed; boundary=\"{b}\"\r\n\r\nThis is a multi-part message in MIME format.\r\n--{b}\r\n{body}"
|
||||||
|
));
|
||||||
|
for a in atts {
|
||||||
|
let ct = if a.content_type.contains('/') {
|
||||||
|
a.content_type.clone()
|
||||||
|
} else {
|
||||||
|
"application/octet-stream".into()
|
||||||
|
};
|
||||||
|
out.push_str(&format!("\r\n--{b}\r\n"));
|
||||||
|
if ct.eq_ignore_ascii_case("message/rfc822") {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"Content-Type: message/rfc822; name={}\r\nContent-Disposition: attachment; filename={}\r\n\r\n",
|
||||||
|
quote_param(&a.name),
|
||||||
|
quote_param(&a.name)
|
||||||
|
));
|
||||||
|
out.push_str(&String::from_utf8_lossy(&a.data));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push_str(&format!(
|
||||||
|
"Content-Type: {ct}; name={}\r\n",
|
||||||
|
quote_param(&a.name)
|
||||||
|
));
|
||||||
|
out.push_str("Content-Transfer-Encoding: base64\r\n");
|
||||||
|
if let Some(cid) = &a.content_id {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"Content-ID: <{}>\r\n",
|
||||||
|
cid.trim_matches(['<', '>'])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str(&format!(
|
||||||
|
"Content-Disposition: {}; filename={}\r\n\r\n",
|
||||||
|
if a.inline { "inline" } else { "attachment" },
|
||||||
|
quote_param(&a.name)
|
||||||
|
));
|
||||||
|
out.push_str(&b64_lines(&a.data));
|
||||||
|
}
|
||||||
|
out.push_str(&format!("--{b}--\r\n"));
|
||||||
|
out.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a raw message into (header block, body) at the first empty line.
|
||||||
|
pub fn split_headers(raw: &[u8]) -> (&[u8], &[u8]) {
|
||||||
|
for (i, w) in raw.windows(4).enumerate() {
|
||||||
|
if w == b"\r\n\r\n" {
|
||||||
|
return (&raw[..i + 2], &raw[i + 4..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, w) in raw.windows(2).enumerate() {
|
||||||
|
if w == b"\n\n" {
|
||||||
|
return (&raw[..i + 1], &raw[i + 2..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(raw, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unfolded raw header lines (name, value).
|
||||||
|
pub fn header_lines(block: &[u8]) -> Vec<(String, String)> {
|
||||||
|
let text = String::from_utf8_lossy(block);
|
||||||
|
let mut out: Vec<(String, String)> = Vec::new();
|
||||||
|
for line in text.split('\n') {
|
||||||
|
let line = line.trim_end_matches('\r');
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if line.starts_with([' ', '\t']) {
|
||||||
|
if let Some(last) = out.last_mut() {
|
||||||
|
last.1.push(' ');
|
||||||
|
last.1.push_str(line.trim());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((k, v)) = line.split_once(':') {
|
||||||
|
out.push((k.trim().to_string(), v.trim().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove headers (case-insensitive) from a raw message, keeping the body untouched.
|
||||||
|
pub fn strip_headers(raw: &[u8], names: &[&str]) -> Vec<u8> {
|
||||||
|
let (h, body) = split_headers(raw);
|
||||||
|
let text = String::from_utf8_lossy(h);
|
||||||
|
let mut out = String::with_capacity(raw.len());
|
||||||
|
let mut skipping = false;
|
||||||
|
for line in text.split_inclusive('\n') {
|
||||||
|
if line.starts_with([' ', '\t']) {
|
||||||
|
if !skipping {
|
||||||
|
out.push_str(line);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = line.split(':').next().unwrap_or("").trim();
|
||||||
|
skipping = names.iter().any(|n| n.eq_ignore_ascii_case(name));
|
||||||
|
if !skipping {
|
||||||
|
out.push_str(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut v = out.into_bytes();
|
||||||
|
if !v.ends_with(b"\n") {
|
||||||
|
v.extend_from_slice(b"\r\n");
|
||||||
|
}
|
||||||
|
v.extend_from_slice(b"\r\n");
|
||||||
|
v.extend_from_slice(body);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepend header lines to a raw message.
|
||||||
|
pub fn prepend_headers(raw: &[u8], headers: &[(String, String)]) -> Vec<u8> {
|
||||||
|
let mut v = Vec::with_capacity(raw.len() + 256);
|
||||||
|
for (k, val) in headers {
|
||||||
|
v.extend_from_slice(format!("{k}: {}\r\n", val.replace(['\r', '\n'], " ")).as_bytes());
|
||||||
|
}
|
||||||
|
v.extend_from_slice(raw);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_and_parse() {
|
||||||
|
let raw = build(
|
||||||
|
&[
|
||||||
|
("Subject".into(), encode_word("Zażółć")),
|
||||||
|
("To".into(), "a@b.pl".into()),
|
||||||
|
],
|
||||||
|
Some("tekst"),
|
||||||
|
Some("<p>tekst</p>"),
|
||||||
|
&[Attachment {
|
||||||
|
name: "plik ą.txt".into(),
|
||||||
|
content_type: "text/plain".into(),
|
||||||
|
data: b"abc".to_vec(),
|
||||||
|
content_id: None,
|
||||||
|
inline: false,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let m = mail_parser::MessageParser::default().parse(&raw).unwrap();
|
||||||
|
assert_eq!(m.subject(), Some("Zażółć"));
|
||||||
|
assert_eq!(m.body_text(0).unwrap(), "tekst");
|
||||||
|
assert_eq!(m.attachment_count(), 1);
|
||||||
|
use mail_parser::MimeHeaders;
|
||||||
|
assert_eq!(
|
||||||
|
m.attachment(0).unwrap().attachment_name(),
|
||||||
|
Some("plik ą.txt")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip() {
|
||||||
|
let raw = b"From: a\r\nBcc: x@y,\r\n z@w\r\nSubject: s\r\n\r\nbody";
|
||||||
|
let s = strip_headers(raw, &["bcc"]);
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8(s).unwrap(),
|
||||||
|
"From: a\r\nSubject: s\r\n\r\nbody"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
//! Outgoing mail: SMTP submission (lettre) or local sendmail binary.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use lettre::address::Envelope;
|
||||||
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
|
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||||
|
use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
use crate::config::{SendMethod, SmtpAuth, SmtpSettings, TlsMode};
|
||||||
|
|
||||||
|
pub async fn send(
|
||||||
|
cfg: &SmtpSettings,
|
||||||
|
login: &str,
|
||||||
|
password: &str,
|
||||||
|
from: &str,
|
||||||
|
recipients: &[String],
|
||||||
|
raw: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
if recipients.is_empty() {
|
||||||
|
bail!("message has no recipients");
|
||||||
|
}
|
||||||
|
if raw.len() as u64 > cfg.max_message_mb * 1024 * 1024 {
|
||||||
|
bail!("message too large");
|
||||||
|
}
|
||||||
|
let from_addr: Address = from
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| anyhow!("invalid sender address {from}"))?;
|
||||||
|
let mut to = Vec::with_capacity(recipients.len());
|
||||||
|
for r in recipients {
|
||||||
|
to.push(
|
||||||
|
r.parse::<Address>()
|
||||||
|
.map_err(|_| anyhow!("invalid recipient address {r}"))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match cfg.method {
|
||||||
|
SendMethod::Smtp => {
|
||||||
|
let tls = |host: &str| -> Result<TlsParameters> {
|
||||||
|
Ok(TlsParameters::builder(host.to_string())
|
||||||
|
.dangerous_accept_invalid_certs(!cfg.verify_tls)
|
||||||
|
.dangerous_accept_invalid_hostnames(!cfg.verify_tls)
|
||||||
|
.build_rustls()?)
|
||||||
|
};
|
||||||
|
let mut b = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
|
||||||
|
.port(cfg.port)
|
||||||
|
.timeout(Some(Duration::from_secs(cfg.timeout_secs.max(5))));
|
||||||
|
b = match cfg.security {
|
||||||
|
TlsMode::None => b.tls(Tls::None),
|
||||||
|
TlsMode::StartTls => b.tls(Tls::Required(tls(&cfg.host)?)),
|
||||||
|
TlsMode::Tls => b.tls(Tls::Wrapper(tls(&cfg.host)?)),
|
||||||
|
};
|
||||||
|
b = match cfg.auth {
|
||||||
|
SmtpAuth::None => b,
|
||||||
|
SmtpAuth::UserCredentials => {
|
||||||
|
b.credentials(Credentials::new(login.to_string(), password.to_string()))
|
||||||
|
}
|
||||||
|
SmtpAuth::Static => {
|
||||||
|
b.credentials(Credentials::new(cfg.username.clone(), cfg.password.clone()))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let transport = b.build();
|
||||||
|
let env = Envelope::new(Some(from_addr), to).context("invalid envelope")?;
|
||||||
|
transport
|
||||||
|
.send_raw(&env, raw)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("SMTP: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
SendMethod::Sendmail => {
|
||||||
|
let mut cmd = tokio::process::Command::new(&cfg.sendmail_path);
|
||||||
|
cmd.arg("-i").arg("-f").arg(&from_addr).arg("--");
|
||||||
|
for t in &to {
|
||||||
|
cmd.arg(t);
|
||||||
|
}
|
||||||
|
cmd.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
let mut child = cmd.spawn().context("spawn sendmail")?;
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
stdin.write_all(raw).await?;
|
||||||
|
stdin.shutdown().await.ok();
|
||||||
|
}
|
||||||
|
let out = tokio::time::timeout(
|
||||||
|
Duration::from_secs(cfg.timeout_secs.max(5)),
|
||||||
|
child.wait_with_output(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("sendmail timeout"))??;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!(
|
||||||
|
"sendmail failed ({}): {}",
|
||||||
|
out.status,
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check connectivity (and optionally authentication) for the panel "test" button.
|
||||||
|
pub async fn test(cfg: &SmtpSettings, login: &str, password: &str) -> Result<String> {
|
||||||
|
match cfg.method {
|
||||||
|
SendMethod::Sendmail => {
|
||||||
|
if std::path::Path::new(&cfg.sendmail_path).exists() {
|
||||||
|
Ok(format!("{} exists", cfg.sendmail_path))
|
||||||
|
} else {
|
||||||
|
bail!("{} not found", cfg.sendmail_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SendMethod::Smtp => {
|
||||||
|
let tls = TlsParameters::builder(cfg.host.clone())
|
||||||
|
.dangerous_accept_invalid_certs(!cfg.verify_tls)
|
||||||
|
.dangerous_accept_invalid_hostnames(!cfg.verify_tls)
|
||||||
|
.build_rustls()?;
|
||||||
|
let mut b = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
|
||||||
|
.port(cfg.port)
|
||||||
|
.timeout(Some(Duration::from_secs(10)));
|
||||||
|
b = match cfg.security {
|
||||||
|
TlsMode::None => b.tls(Tls::None),
|
||||||
|
TlsMode::StartTls => b.tls(Tls::Required(tls)),
|
||||||
|
TlsMode::Tls => b.tls(Tls::Wrapper(tls)),
|
||||||
|
};
|
||||||
|
match cfg.auth {
|
||||||
|
SmtpAuth::UserCredentials if !login.is_empty() => {
|
||||||
|
b = b.credentials(Credentials::new(login.into(), password.into()))
|
||||||
|
}
|
||||||
|
SmtpAuth::Static => {
|
||||||
|
b = b.credentials(Credentials::new(cfg.username.clone(), cfg.password.clone()))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let t: AsyncSmtpTransport<Tokio1Executor> = b.build();
|
||||||
|
let ok = t.test_connection().await.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
if ok {
|
||||||
|
Ok(format!("SMTP {}:{} OK", cfg.host, cfg.port))
|
||||||
|
} else {
|
||||||
|
bail!("SMTP connection test failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
//! Time zone helpers: EAS TIME_ZONE_INFORMATION blobs <-> IANA zones, date formats, iCal dates.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, Offset, TimeZone, Timelike, Utc};
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
|
||||||
|
use super::ical::{Component, Property};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct SysTime {
|
||||||
|
pub year: u16,
|
||||||
|
pub month: u16,
|
||||||
|
pub dow: u16,
|
||||||
|
pub day: u16,
|
||||||
|
pub hour: u16,
|
||||||
|
pub minute: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct TzRule {
|
||||||
|
pub bias: i32,
|
||||||
|
pub std_date: SysTime,
|
||||||
|
pub std_bias: i32,
|
||||||
|
pub dst_date: SysTime,
|
||||||
|
pub dst_bias: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset_min(tz: &Tz, t: DateTime<Utc>) -> i32 {
|
||||||
|
t.with_timezone(tz).offset().fix().local_minus_utc() / 60
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sys_from_local(local: NaiveDateTime) -> SysTime {
|
||||||
|
let day = local.day();
|
||||||
|
let dim = days_in_month(local.year(), local.month());
|
||||||
|
let week = if day + 7 > dim { 5 } else { (day - 1) / 7 + 1 };
|
||||||
|
SysTime {
|
||||||
|
year: 0,
|
||||||
|
month: local.month() as u16,
|
||||||
|
dow: local.weekday().num_days_from_sunday() as u16,
|
||||||
|
day: week as u16,
|
||||||
|
hour: local.hour() as u16,
|
||||||
|
minute: local.minute() as u16,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn days_in_month(y: i32, m: u32) -> u32 {
|
||||||
|
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
|
||||||
|
NaiveDate::from_ymd_opt(ny, nm, 1)
|
||||||
|
.and_then(|d| d.pred_opt())
|
||||||
|
.map(|d| d.day())
|
||||||
|
.unwrap_or(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the Windows-style rule for a zone in a given year.
|
||||||
|
pub fn rule_for(tz: &Tz, year: i32) -> TzRule {
|
||||||
|
static CACHE: OnceLock<Mutex<HashMap<(String, i32), TzRule>>> = OnceLock::new();
|
||||||
|
let key = (tz.name().to_string(), year);
|
||||||
|
if let Some(r) = CACHE
|
||||||
|
.get_or_init(Default::default)
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&key)
|
||||||
|
{
|
||||||
|
return *r;
|
||||||
|
}
|
||||||
|
let start = Utc.with_ymd_and_hms(year, 1, 1, 0, 0, 0).unwrap();
|
||||||
|
let mut transitions = Vec::new();
|
||||||
|
let mut prev = offset_min(tz, start);
|
||||||
|
let mut min_off = prev;
|
||||||
|
let mut max_off = prev;
|
||||||
|
let mut t = start;
|
||||||
|
// hourly scan, then refine to the minute
|
||||||
|
for _ in 0..(366 * 24) {
|
||||||
|
let n = t + chrono::Duration::hours(1);
|
||||||
|
let o = offset_min(tz, n);
|
||||||
|
if o != prev {
|
||||||
|
let mut lo = t;
|
||||||
|
let mut hi = n;
|
||||||
|
while (hi - lo).num_minutes() > 1 {
|
||||||
|
let mid = lo + (hi - lo) / 2;
|
||||||
|
if offset_min(tz, mid) == prev {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transitions.push((hi, prev, o));
|
||||||
|
prev = o;
|
||||||
|
}
|
||||||
|
min_off = min_off.min(o);
|
||||||
|
max_off = max_off.max(o);
|
||||||
|
t = n;
|
||||||
|
if t.year() > year {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut rule = TzRule {
|
||||||
|
bias: -min_off,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if max_off != min_off && transitions.len() >= 2 {
|
||||||
|
rule.dst_bias = -(max_off - min_off);
|
||||||
|
for (instant, from, to) in transitions {
|
||||||
|
if to > from {
|
||||||
|
// into daylight: expressed in local standard time
|
||||||
|
let local = (instant + chrono::Duration::minutes(from as i64)).naive_utc();
|
||||||
|
rule.dst_date = sys_from_local(local);
|
||||||
|
} else {
|
||||||
|
// into standard: expressed in local daylight time
|
||||||
|
let local = (instant + chrono::Duration::minutes(from as i64)).naive_utc();
|
||||||
|
rule.std_date = sys_from_local(local);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CACHE
|
||||||
|
.get_or_init(Default::default)
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(key, rule);
|
||||||
|
rule
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_sys(out: &mut Vec<u8>, s: &SysTime) {
|
||||||
|
for v in [s.year, s.month, s.dow, s.day, s.hour, s.minute, 0, 0] {
|
||||||
|
out.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_name(out: &mut Vec<u8>, name: &str) {
|
||||||
|
let mut units: Vec<u16> = name.encode_utf16().take(31).collect();
|
||||||
|
units.resize(32, 0);
|
||||||
|
for u in units {
|
||||||
|
out.extend_from_slice(&u.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blob_for(tz: &Tz, year: i32) -> String {
|
||||||
|
let r = rule_for(tz, year);
|
||||||
|
let mut out = Vec::with_capacity(172);
|
||||||
|
out.extend_from_slice(&r.bias.to_le_bytes());
|
||||||
|
write_name(&mut out, tz.name());
|
||||||
|
write_sys(&mut out, &r.std_date);
|
||||||
|
out.extend_from_slice(&r.std_bias.to_le_bytes());
|
||||||
|
write_name(&mut out, tz.name());
|
||||||
|
write_sys(&mut out, &r.dst_date);
|
||||||
|
out.extend_from_slice(&r.dst_bias.to_le_bytes());
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_blob(b64: &str) -> Option<TzRule> {
|
||||||
|
let b = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(b64.trim())
|
||||||
|
.ok()?;
|
||||||
|
if b.len() < 172 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let i32_at = |o: usize| i32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
|
||||||
|
let u16_at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
||||||
|
let sys_at = |o: usize| SysTime {
|
||||||
|
year: u16_at(o),
|
||||||
|
month: u16_at(o + 2),
|
||||||
|
dow: u16_at(o + 4),
|
||||||
|
day: u16_at(o + 6),
|
||||||
|
hour: u16_at(o + 8),
|
||||||
|
minute: u16_at(o + 10),
|
||||||
|
};
|
||||||
|
Some(TzRule {
|
||||||
|
bias: i32_at(0),
|
||||||
|
std_date: sys_at(68),
|
||||||
|
std_bias: i32_at(84),
|
||||||
|
dst_date: sys_at(152),
|
||||||
|
dst_bias: i32_at(168),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rules_match(a: &TzRule, b: &TzRule) -> bool {
|
||||||
|
let norm = |s: &SysTime| (s.month, s.dow, s.day, s.hour);
|
||||||
|
a.bias == b.bias
|
||||||
|
&& (a.dst_date.month == 0) == (b.dst_date.month == 0)
|
||||||
|
&& (a.dst_date.month == 0
|
||||||
|
|| (norm(&a.std_date) == norm(&b.std_date)
|
||||||
|
&& norm(&a.dst_date) == norm(&b.dst_date)
|
||||||
|
&& a.dst_bias == b.dst_bias))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a device-supplied blob to an IANA zone (preferring `preferred`, then common zones).
|
||||||
|
pub fn tz_from_blob(b64: &str, preferred: Tz) -> Tz {
|
||||||
|
static CACHE: OnceLock<Mutex<HashMap<(String, Tz), Tz>>> = OnceLock::new();
|
||||||
|
let key = (b64.to_string(), preferred);
|
||||||
|
if let Some(t) = CACHE
|
||||||
|
.get_or_init(Default::default)
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&key)
|
||||||
|
{
|
||||||
|
return *t;
|
||||||
|
}
|
||||||
|
let Some(rule) = parse_blob(b64) else {
|
||||||
|
return preferred;
|
||||||
|
};
|
||||||
|
let year = Utc::now().year();
|
||||||
|
const COMMON: &[&str] = &[
|
||||||
|
"Europe/Warsaw",
|
||||||
|
"Europe/Berlin",
|
||||||
|
"Europe/London",
|
||||||
|
"Europe/Kiev",
|
||||||
|
"Europe/Moscow",
|
||||||
|
"America/New_York",
|
||||||
|
"America/Chicago",
|
||||||
|
"America/Denver",
|
||||||
|
"America/Los_Angeles",
|
||||||
|
"Asia/Tokyo",
|
||||||
|
"Asia/Shanghai",
|
||||||
|
"Australia/Sydney",
|
||||||
|
];
|
||||||
|
let found = if rules_match(&rule_for(&preferred, year), &rule) {
|
||||||
|
preferred
|
||||||
|
} else if rule.bias == 0 && rule.dst_date.month == 0 {
|
||||||
|
chrono_tz::UTC
|
||||||
|
} else {
|
||||||
|
COMMON
|
||||||
|
.iter()
|
||||||
|
.filter_map(|n| n.parse::<Tz>().ok())
|
||||||
|
.chain(
|
||||||
|
chrono_tz::TZ_VARIANTS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|z| !z.name().starts_with("Etc/") || rule.dst_date.month == 0),
|
||||||
|
)
|
||||||
|
.find(|z| rules_match(&rule_for(z, year), &rule))
|
||||||
|
.unwrap_or(preferred)
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut c = CACHE.get_or_init(Default::default).lock().unwrap();
|
||||||
|
if c.len() > 2000 {
|
||||||
|
c.clear();
|
||||||
|
}
|
||||||
|
c.insert(key, found);
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ EAS date formats
|
||||||
|
|
||||||
|
pub fn fmt_compact(d: &DateTime<Utc>) -> String {
|
||||||
|
d.format("%Y%m%dT%H%M%SZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fmt_iso(d: &DateTime<Utc>) -> String {
|
||||||
|
d.format("%Y-%m-%dT%H:%M:%S.000Z").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_eas_date(s: &str) -> Option<DateTime<Utc>> {
|
||||||
|
let s = s.trim();
|
||||||
|
for f in [
|
||||||
|
"%Y%m%dT%H%M%SZ",
|
||||||
|
"%Y-%m-%dT%H:%M:%S%.fZ",
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ",
|
||||||
|
"%Y%m%dT%H%M%S",
|
||||||
|
] {
|
||||||
|
if let Ok(n) = NaiveDateTime::parse_from_str(s, f) {
|
||||||
|
return Some(Utc.from_utc_datetime(&n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DateTime::parse_from_rfc3339(s)
|
||||||
|
.ok()
|
||||||
|
.map(|d| d.with_timezone(&Utc))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ iCalendar dates
|
||||||
|
|
||||||
|
/// Resolve a TZID parameter to an IANA zone.
|
||||||
|
pub fn resolve_tzid(tzid: &str, default: Tz) -> Tz {
|
||||||
|
let t = tzid.trim().trim_matches('"');
|
||||||
|
if let Ok(z) = t.parse::<Tz>() {
|
||||||
|
return z;
|
||||||
|
}
|
||||||
|
// /mozilla.org/20050126_1/Europe/Warsaw, /freeassociation.sourceforge.net/Tzfile/Europe/Warsaw
|
||||||
|
let segs: Vec<&str> = t.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
for n in [3usize, 2, 1] {
|
||||||
|
if segs.len() >= n {
|
||||||
|
if let Ok(z) = segs[segs.len() - n..].join("/").parse::<Tz>() {
|
||||||
|
return z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let windows = [
|
||||||
|
("Central European Standard Time", "Europe/Warsaw"),
|
||||||
|
("Central Europe Standard Time", "Europe/Budapest"),
|
||||||
|
("W. Europe Standard Time", "Europe/Berlin"),
|
||||||
|
("Romance Standard Time", "Europe/Paris"),
|
||||||
|
("GMT Standard Time", "Europe/London"),
|
||||||
|
("Greenwich Standard Time", "Atlantic/Reykjavik"),
|
||||||
|
("E. Europe Standard Time", "Europe/Chisinau"),
|
||||||
|
("FLE Standard Time", "Europe/Kiev"),
|
||||||
|
("GTB Standard Time", "Europe/Bucharest"),
|
||||||
|
("Russian Standard Time", "Europe/Moscow"),
|
||||||
|
("Eastern Standard Time", "America/New_York"),
|
||||||
|
("Central Standard Time", "America/Chicago"),
|
||||||
|
("Mountain Standard Time", "America/Denver"),
|
||||||
|
("Pacific Standard Time", "America/Los_Angeles"),
|
||||||
|
("UTC", "UTC"),
|
||||||
|
("Coordinated Universal Time", "UTC"),
|
||||||
|
("China Standard Time", "Asia/Shanghai"),
|
||||||
|
("Tokyo Standard Time", "Asia/Tokyo"),
|
||||||
|
("India Standard Time", "Asia/Kolkata"),
|
||||||
|
("AUS Eastern Standard Time", "Australia/Sydney"),
|
||||||
|
];
|
||||||
|
for (w, i) in windows {
|
||||||
|
if t.eq_ignore_ascii_case(w) {
|
||||||
|
return i.parse().unwrap_or(default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct IcalTime {
|
||||||
|
pub utc: DateTime<Utc>,
|
||||||
|
pub all_day: bool,
|
||||||
|
pub tz: Option<Tz>,
|
||||||
|
pub date: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_ical_time(p: &Property, default: Tz) -> Option<IcalTime> {
|
||||||
|
let v = p.value.trim();
|
||||||
|
let is_date = p
|
||||||
|
.param("VALUE")
|
||||||
|
.map(|x| x.eq_ignore_ascii_case("DATE"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| (v.len() == 8 && v.chars().all(|c| c.is_ascii_digit()));
|
||||||
|
if is_date {
|
||||||
|
let d = NaiveDate::parse_from_str(&v[..8.min(v.len())], "%Y%m%d").ok()?;
|
||||||
|
let local = d.and_hms_opt(0, 0, 0)?;
|
||||||
|
let utc = default
|
||||||
|
.from_local_datetime(&local)
|
||||||
|
.earliest()
|
||||||
|
.map(|x| x.with_timezone(&Utc))?;
|
||||||
|
return Some(IcalTime {
|
||||||
|
utc,
|
||||||
|
all_day: true,
|
||||||
|
tz: None,
|
||||||
|
date: Some(d),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(stripped) = v.strip_suffix('Z') {
|
||||||
|
let n = NaiveDateTime::parse_from_str(stripped, "%Y%m%dT%H%M%S").ok()?;
|
||||||
|
return Some(IcalTime {
|
||||||
|
utc: Utc.from_utc_datetime(&n),
|
||||||
|
all_day: false,
|
||||||
|
tz: None,
|
||||||
|
date: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let n = NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%S").ok()?;
|
||||||
|
let tz = p
|
||||||
|
.param("TZID")
|
||||||
|
.map(|t| resolve_tzid(t, default))
|
||||||
|
.unwrap_or(default);
|
||||||
|
let utc = tz
|
||||||
|
.from_local_datetime(&n)
|
||||||
|
.earliest()
|
||||||
|
.or_else(|| {
|
||||||
|
tz.from_local_datetime(&(n + chrono::Duration::hours(1)))
|
||||||
|
.earliest()
|
||||||
|
})
|
||||||
|
.map(|x| x.with_timezone(&Utc))?;
|
||||||
|
Some(IcalTime {
|
||||||
|
utc,
|
||||||
|
all_day: false,
|
||||||
|
tz: Some(tz),
|
||||||
|
date: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ical_utc(name: &str, d: &DateTime<Utc>) -> Property {
|
||||||
|
Property::new(name, d.format("%Y%m%dT%H%M%SZ").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ical_local(name: &str, d: &DateTime<Utc>, tz: &Tz) -> Property {
|
||||||
|
Property::new(
|
||||||
|
name,
|
||||||
|
d.with_timezone(tz).format("%Y%m%dT%H%M%S").to_string(),
|
||||||
|
)
|
||||||
|
.with_param("TZID", tz.name())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ical_date(name: &str, d: NaiveDate) -> Property {
|
||||||
|
Property::new(name, d.format("%Y%m%d").to_string()).with_param("VALUE", "DATE")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a VTIMEZONE for `tz` using the rules of the current year.
|
||||||
|
pub fn vtimezone(tz: &Tz) -> Component {
|
||||||
|
let mut c = Component::new("VTIMEZONE");
|
||||||
|
c.add(Property::new("TZID", tz.name()));
|
||||||
|
let year = Utc::now().year();
|
||||||
|
let r = rule_for(tz, year);
|
||||||
|
let fmt_off = |m: i32| {
|
||||||
|
let s = if m < 0 { '-' } else { '+' };
|
||||||
|
format!("{s}{:02}{:02}", m.abs() / 60, m.abs() % 60)
|
||||||
|
};
|
||||||
|
let std_off = -r.bias;
|
||||||
|
if r.dst_date.month == 0 {
|
||||||
|
let mut s = Component::new("STANDARD");
|
||||||
|
s.add(Property::new("DTSTART", "19700101T000000"));
|
||||||
|
s.add(Property::new("TZOFFSETFROM", fmt_off(std_off)));
|
||||||
|
s.add(Property::new("TZOFFSETTO", fmt_off(std_off)));
|
||||||
|
c.children.push(s);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
let dst_off = std_off - r.dst_bias;
|
||||||
|
let byday = |st: &SysTime| {
|
||||||
|
let wd = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"][st.dow as usize % 7];
|
||||||
|
let n = if st.day >= 5 {
|
||||||
|
"-1".to_string()
|
||||||
|
} else {
|
||||||
|
st.day.to_string()
|
||||||
|
};
|
||||||
|
format!("FREQ=YEARLY;BYMONTH={};BYDAY={n}{wd}", st.month)
|
||||||
|
};
|
||||||
|
let mut d = Component::new("DAYLIGHT");
|
||||||
|
d.add(Property::new(
|
||||||
|
"DTSTART",
|
||||||
|
format!("19700101T{:02}{:02}00", r.dst_date.hour, r.dst_date.minute),
|
||||||
|
));
|
||||||
|
d.add(Property::new("RRULE", byday(&r.dst_date)));
|
||||||
|
d.add(Property::new("TZOFFSETFROM", fmt_off(std_off)));
|
||||||
|
d.add(Property::new("TZOFFSETTO", fmt_off(dst_off)));
|
||||||
|
let mut s = Component::new("STANDARD");
|
||||||
|
s.add(Property::new(
|
||||||
|
"DTSTART",
|
||||||
|
format!("19700101T{:02}{:02}00", r.std_date.hour, r.std_date.minute),
|
||||||
|
));
|
||||||
|
s.add(Property::new("RRULE", byday(&r.std_date)));
|
||||||
|
s.add(Property::new("TZOFFSETFROM", fmt_off(dst_off)));
|
||||||
|
s.add(Property::new("TZOFFSETTO", fmt_off(std_off)));
|
||||||
|
c.children.push(d);
|
||||||
|
c.children.push(s);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warsaw_rule() {
|
||||||
|
let r = rule_for(&chrono_tz::Europe::Warsaw, 2026);
|
||||||
|
assert_eq!(r.bias, -60);
|
||||||
|
assert_eq!(r.dst_bias, -60);
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
r.dst_date.month,
|
||||||
|
r.dst_date.day,
|
||||||
|
r.dst_date.dow,
|
||||||
|
r.dst_date.hour
|
||||||
|
),
|
||||||
|
(3, 5, 0, 2)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
r.std_date.month,
|
||||||
|
r.std_date.day,
|
||||||
|
r.std_date.dow,
|
||||||
|
r.std_date.hour
|
||||||
|
),
|
||||||
|
(10, 5, 0, 3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blob_roundtrip() {
|
||||||
|
let b = blob_for(&chrono_tz::America::New_York, 2026);
|
||||||
|
let tz = tz_from_blob(&b, chrono_tz::Europe::Warsaw);
|
||||||
|
let r1 = rule_for(&tz, 2026);
|
||||||
|
assert_eq!(r1, rule_for(&chrono_tz::America::New_York, 2026));
|
||||||
|
let w = blob_for(&chrono_tz::Europe::Warsaw, 2026);
|
||||||
|
assert_eq!(
|
||||||
|
tz_from_blob(&w, chrono_tz::Europe::Warsaw),
|
||||||
|
chrono_tz::Europe::Warsaw
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ical_times() {
|
||||||
|
let p = Property::new("DTSTART", "20260704T100000")
|
||||||
|
.with_param("TZID", "/mozilla.org/x/Europe/Warsaw");
|
||||||
|
let t = parse_ical_time(&p, chrono_tz::UTC).unwrap();
|
||||||
|
assert_eq!(fmt_compact(&t.utc), "20260704T080000Z");
|
||||||
|
let d = parse_ical_time(
|
||||||
|
&Property::new("DTSTART", "20260704").with_param("VALUE", "DATE"),
|
||||||
|
chrono_tz::Europe::Warsaw,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(d.all_day);
|
||||||
|
assert_eq!(fmt_compact(&d.utc), "20260703T220000Z");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
//! Runtime settings. Persisted as JSON in SQLite, editable from the admin panel and hot-applied.
|
||||||
|
//! Defaults mirror Z-Push `config.php`, `backend/*/config.php` and `policies.ini`.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TlsMode {
|
||||||
|
None,
|
||||||
|
#[default]
|
||||||
|
StartTls,
|
||||||
|
Tls,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LoginFormat {
|
||||||
|
/// Use the login exactly as the device sent it (after stripping a Windows `DOMAIN\`).
|
||||||
|
#[default]
|
||||||
|
AsGiven,
|
||||||
|
/// Use only the local part (`user` from `user@example.com`) - Z-Push USE_FULLEMAIL_FOR_LOGIN=false.
|
||||||
|
LocalPart,
|
||||||
|
/// Always use a full address; append `default_domain` when missing.
|
||||||
|
FullEmail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub general: General,
|
||||||
|
pub imap: ImapSettings,
|
||||||
|
pub smtp: SmtpSettings,
|
||||||
|
pub caldav: DavSettings,
|
||||||
|
pub carddav: CardDavSettings,
|
||||||
|
pub sync: SyncSettings,
|
||||||
|
pub ping: PingSettings,
|
||||||
|
pub provisioning: Provisioning,
|
||||||
|
pub autodiscover: Autodiscover,
|
||||||
|
pub security: Security,
|
||||||
|
pub logging: Logging,
|
||||||
|
pub panel: Panel,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct General {
|
||||||
|
/// Shown in the panel and used for autodiscover display name.
|
||||||
|
pub server_name: String,
|
||||||
|
/// IANA zone used for all-day events and recurring items when the device gives no zone.
|
||||||
|
pub default_timezone: String,
|
||||||
|
pub login_format: LoginFormat,
|
||||||
|
/// Domain appended for FullEmail login and used for `{domain}` in templates.
|
||||||
|
pub default_domain: String,
|
||||||
|
/// If non-empty, only these domains may log in (lowercase).
|
||||||
|
pub allowed_domains: Vec<String>,
|
||||||
|
/// Template for the user's primary e-mail address. Placeholders: {login} {local} {domain}.
|
||||||
|
pub email_template: String,
|
||||||
|
/// Highest ActiveSync protocol version announced to devices.
|
||||||
|
pub max_protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for General {
|
||||||
|
fn default() -> Self {
|
||||||
|
General {
|
||||||
|
server_name: "ActivePush".into(),
|
||||||
|
default_timezone: "Europe/Warsaw".into(),
|
||||||
|
login_format: LoginFormat::AsGiven,
|
||||||
|
default_domain: String::new(),
|
||||||
|
allowed_domains: vec![],
|
||||||
|
email_template: "{login}".into(),
|
||||||
|
max_protocol: "14.1".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ImapSettings {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub security: TlsMode,
|
||||||
|
pub verify_tls: bool,
|
||||||
|
/// Optional namespace prefix (e.g. "INBOX.") - Z-Push IMAP_FOLDER_PREFIX.
|
||||||
|
pub folder_prefix: String,
|
||||||
|
pub folder_inbox: String,
|
||||||
|
pub folder_sent: String,
|
||||||
|
pub folder_drafts: String,
|
||||||
|
pub folder_trash: String,
|
||||||
|
pub folder_spam: String,
|
||||||
|
pub folder_archive: String,
|
||||||
|
/// Prefer RFC 6154 SPECIAL-USE flags over the names above.
|
||||||
|
pub use_special_use: bool,
|
||||||
|
/// Folders hidden from devices (exact names or `prefix*`).
|
||||||
|
pub excluded_folders: Vec<String>,
|
||||||
|
/// Mark messages \Seen before moving them to Trash - IMAP_AUTOSEEN_ON_DELETE.
|
||||||
|
pub autoseen_on_delete: bool,
|
||||||
|
/// Inline (true) or attached (false) forward body - IMAP_INLINE_FORWARD.
|
||||||
|
pub inline_forward: bool,
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
/// Keep authenticated connections for reuse between requests of the same user.
|
||||||
|
pub pool_idle_secs: u64,
|
||||||
|
pub pool_max_per_user: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ImapSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
ImapSettings {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: 143,
|
||||||
|
security: TlsMode::None,
|
||||||
|
verify_tls: true,
|
||||||
|
folder_prefix: String::new(),
|
||||||
|
folder_inbox: "INBOX".into(),
|
||||||
|
folder_sent: "Sent".into(),
|
||||||
|
folder_drafts: "Drafts".into(),
|
||||||
|
folder_trash: "Trash".into(),
|
||||||
|
folder_spam: "Junk".into(),
|
||||||
|
folder_archive: "Archive".into(),
|
||||||
|
use_special_use: true,
|
||||||
|
excluded_folders: vec![],
|
||||||
|
autoseen_on_delete: false,
|
||||||
|
inline_forward: true,
|
||||||
|
timeout_secs: 60,
|
||||||
|
pool_idle_secs: 90,
|
||||||
|
pool_max_per_user: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SendMethod {
|
||||||
|
#[default]
|
||||||
|
Smtp,
|
||||||
|
Sendmail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SmtpAuth {
|
||||||
|
None,
|
||||||
|
/// Authenticate with the device user's own credentials (recommended with submission port).
|
||||||
|
#[default]
|
||||||
|
UserCredentials,
|
||||||
|
/// Authenticate with a fixed relay account.
|
||||||
|
Static,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SmtpSettings {
|
||||||
|
pub method: SendMethod,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub security: TlsMode,
|
||||||
|
pub verify_tls: bool,
|
||||||
|
pub auth: SmtpAuth,
|
||||||
|
pub username: String,
|
||||||
|
/// Secret - never returned by the API.
|
||||||
|
pub password: String,
|
||||||
|
pub sendmail_path: String,
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
/// Store a copy of sent mail in the Sent folder when the device asks for it.
|
||||||
|
pub save_in_sent: bool,
|
||||||
|
/// Reject messages whose From is not one of the user's addresses (anti-spoofing).
|
||||||
|
pub enforce_from: bool,
|
||||||
|
pub max_message_mb: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SmtpSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
SmtpSettings {
|
||||||
|
method: SendMethod::Smtp,
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: 587,
|
||||||
|
security: TlsMode::StartTls,
|
||||||
|
verify_tls: false,
|
||||||
|
auth: SmtpAuth::UserCredentials,
|
||||||
|
username: String::new(),
|
||||||
|
password: String::new(),
|
||||||
|
sendmail_path: "/usr/sbin/sendmail".into(),
|
||||||
|
timeout_secs: 60,
|
||||||
|
save_in_sent: true,
|
||||||
|
enforce_from: true,
|
||||||
|
max_message_mb: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DavSettings {
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Base URL of the server, e.g. `https://dav.example.com`.
|
||||||
|
pub base_url: String,
|
||||||
|
/// Path to the user's calendar home. Placeholders: %u login, %l local part, %d domain.
|
||||||
|
pub path: String,
|
||||||
|
pub verify_tls: bool,
|
||||||
|
/// Only collections whose path matches this regex (empty = all).
|
||||||
|
pub include_regex: String,
|
||||||
|
pub exclude_regex: String,
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
/// Name of the default calendar (path segment); empty = first found.
|
||||||
|
pub default_collection: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DavSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
DavSettings {
|
||||||
|
enabled: false,
|
||||||
|
base_url: "https://127.0.0.1".into(),
|
||||||
|
path: "/dav/calendars/%u/".into(),
|
||||||
|
verify_tls: true,
|
||||||
|
include_regex: String::new(),
|
||||||
|
exclude_regex: String::new(),
|
||||||
|
timeout_secs: 60,
|
||||||
|
default_collection: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct CardDavSettings {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub base_url: String,
|
||||||
|
pub path: String,
|
||||||
|
pub verify_tls: bool,
|
||||||
|
pub include_regex: String,
|
||||||
|
pub exclude_regex: String,
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
pub default_collection: String,
|
||||||
|
/// Global address list collection (GAL search); empty = search the user's books.
|
||||||
|
pub gal_path: String,
|
||||||
|
pub gal_min_length: usize,
|
||||||
|
pub max_picture_kb: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CardDavSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
CardDavSettings {
|
||||||
|
enabled: false,
|
||||||
|
base_url: "https://127.0.0.1".into(),
|
||||||
|
path: "/dav/addressbooks/%u/".into(),
|
||||||
|
verify_tls: true,
|
||||||
|
include_regex: String::new(),
|
||||||
|
exclude_regex: String::new(),
|
||||||
|
timeout_secs: 60,
|
||||||
|
default_collection: String::new(),
|
||||||
|
gal_path: String::new(),
|
||||||
|
gal_min_length: 3,
|
||||||
|
max_picture_kb: 5120,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SyncSettings {
|
||||||
|
/// SYNC_MAX_ITEMS - upper bound for WindowSize.
|
||||||
|
pub max_items: u32,
|
||||||
|
/// SYNC_FILTERTIME_MAX - 0 = no limit, otherwise EAS filter type 1..7.
|
||||||
|
pub filter_time_max: u8,
|
||||||
|
/// SYNC_CONFLICT_DEFAULT: true = server wins (overwrite PIM), false = client wins.
|
||||||
|
pub server_wins: bool,
|
||||||
|
pub max_attachment_mb: u64,
|
||||||
|
pub max_request_mb: u64,
|
||||||
|
/// Hanging Sync (Wait / HeartbeatInterval) bounds in seconds.
|
||||||
|
pub heartbeat_min: u64,
|
||||||
|
pub heartbeat_max: u64,
|
||||||
|
/// Detect a device retrying the same SyncKey and shrink its window (loop detection).
|
||||||
|
pub loop_detection: bool,
|
||||||
|
/// Max seconds a Search waits - SEARCH_WAIT.
|
||||||
|
pub search_wait_secs: u64,
|
||||||
|
/// SEARCH_MAXRESULTS
|
||||||
|
pub search_max_results: u32,
|
||||||
|
/// Retry-After for 503 responses - RETRY_AFTER_DELAY.
|
||||||
|
pub retry_after_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SyncSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
SyncSettings {
|
||||||
|
max_items: 512,
|
||||||
|
filter_time_max: 0,
|
||||||
|
server_wins: true,
|
||||||
|
max_attachment_mb: 50,
|
||||||
|
max_request_mb: 60,
|
||||||
|
heartbeat_min: 60,
|
||||||
|
heartbeat_max: 3540,
|
||||||
|
loop_detection: true,
|
||||||
|
search_wait_secs: 10,
|
||||||
|
search_max_results: 50,
|
||||||
|
retry_after_secs: 300,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct PingSettings {
|
||||||
|
/// PING_INTERVAL - how often backend changes are checked while a Ping hangs.
|
||||||
|
pub check_interval_secs: u64,
|
||||||
|
/// PING_LOWER_BOUND_LIFETIME / PING_HIGHER_BOUND_LIFETIME.
|
||||||
|
pub lifetime_min: u64,
|
||||||
|
pub lifetime_max: u64,
|
||||||
|
pub max_folders: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PingSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
PingSettings {
|
||||||
|
check_interval_secs: 30,
|
||||||
|
lifetime_min: 60,
|
||||||
|
lifetime_max: 3540,
|
||||||
|
max_folders: 300,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Provisioning {
|
||||||
|
pub enabled: bool,
|
||||||
|
/// LOOSE_PROVISIONING - allow devices that never provision.
|
||||||
|
pub loose: bool,
|
||||||
|
pub default_policy: String,
|
||||||
|
pub policies: Vec<Policy>,
|
||||||
|
/// Per-user policy assignment: (username, policy name).
|
||||||
|
pub user_policies: Vec<UserPolicy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Provisioning {
|
||||||
|
fn default() -> Self {
|
||||||
|
Provisioning {
|
||||||
|
enabled: true,
|
||||||
|
loose: false,
|
||||||
|
default_policy: "default".into(),
|
||||||
|
policies: vec![Policy::default()],
|
||||||
|
user_policies: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct UserPolicy {
|
||||||
|
pub username: String,
|
||||||
|
pub policy: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All fields from Z-Push `policies.ini`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Policy {
|
||||||
|
pub name: String,
|
||||||
|
pub devpwenabled: bool,
|
||||||
|
pub alphanumpwreq: bool,
|
||||||
|
pub devencenabled: bool,
|
||||||
|
pub attenabled: bool,
|
||||||
|
pub mindevpwlenngth: u32,
|
||||||
|
pub maxinacttimedevlock: u32,
|
||||||
|
pub maxdevpwfailedattempts: u32,
|
||||||
|
/// -1 = unlimited
|
||||||
|
pub maxattsize: i64,
|
||||||
|
pub allowsimpledevpw: bool,
|
||||||
|
pub devpwexpiration: u32,
|
||||||
|
pub devpwhistory: u32,
|
||||||
|
pub allowstoragecard: bool,
|
||||||
|
pub allowcam: bool,
|
||||||
|
pub reqdevenc: bool,
|
||||||
|
pub allowunsignedapps: bool,
|
||||||
|
pub allowunsignedinstallationpackages: bool,
|
||||||
|
pub mindevcomplexchars: u32,
|
||||||
|
pub allowwifi: bool,
|
||||||
|
pub allowtextmessaging: bool,
|
||||||
|
pub allowpopimapemail: bool,
|
||||||
|
/// 0 disabled, 1 handsfree only, 2 allowed
|
||||||
|
pub allowbluetooth: u32,
|
||||||
|
pub allowirda: bool,
|
||||||
|
pub reqmansyncroam: bool,
|
||||||
|
pub allowdesktopsync: bool,
|
||||||
|
pub maxcalagefilter: u32,
|
||||||
|
pub allowhtmlemail: bool,
|
||||||
|
pub maxemailagefilter: u32,
|
||||||
|
pub maxemailbodytruncsize: i64,
|
||||||
|
pub maxemailhtmlbodytruncsize: i64,
|
||||||
|
pub reqsignedsmimemessages: bool,
|
||||||
|
pub reqencsmimemessages: bool,
|
||||||
|
pub reqsignedsmimealgorithm: u32,
|
||||||
|
pub reqencsmimealgorithm: u32,
|
||||||
|
pub allowsmimeencalgneg: u32,
|
||||||
|
pub allowsmimesoftcerts: bool,
|
||||||
|
pub allowbrowser: bool,
|
||||||
|
pub allowconsumeremail: bool,
|
||||||
|
pub allowremotedesktop: bool,
|
||||||
|
pub allowinternetsharing: bool,
|
||||||
|
pub pwrecoveryenabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Policy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Policy {
|
||||||
|
name: "default".into(),
|
||||||
|
devpwenabled: false,
|
||||||
|
alphanumpwreq: false,
|
||||||
|
devencenabled: false,
|
||||||
|
attenabled: true,
|
||||||
|
mindevpwlenngth: 4,
|
||||||
|
maxinacttimedevlock: 900,
|
||||||
|
maxdevpwfailedattempts: 8,
|
||||||
|
maxattsize: -1,
|
||||||
|
allowsimpledevpw: true,
|
||||||
|
devpwexpiration: 0,
|
||||||
|
devpwhistory: 0,
|
||||||
|
allowstoragecard: true,
|
||||||
|
allowcam: true,
|
||||||
|
reqdevenc: false,
|
||||||
|
allowunsignedapps: true,
|
||||||
|
allowunsignedinstallationpackages: true,
|
||||||
|
mindevcomplexchars: 3,
|
||||||
|
allowwifi: true,
|
||||||
|
allowtextmessaging: true,
|
||||||
|
allowpopimapemail: true,
|
||||||
|
allowbluetooth: 2,
|
||||||
|
allowirda: true,
|
||||||
|
reqmansyncroam: false,
|
||||||
|
allowdesktopsync: true,
|
||||||
|
maxcalagefilter: 0,
|
||||||
|
allowhtmlemail: true,
|
||||||
|
maxemailagefilter: 0,
|
||||||
|
maxemailbodytruncsize: -1,
|
||||||
|
maxemailhtmlbodytruncsize: -1,
|
||||||
|
reqsignedsmimemessages: false,
|
||||||
|
reqencsmimemessages: false,
|
||||||
|
reqsignedsmimealgorithm: 0,
|
||||||
|
reqencsmimealgorithm: 0,
|
||||||
|
allowsmimeencalgneg: 2,
|
||||||
|
allowsmimesoftcerts: true,
|
||||||
|
allowbrowser: true,
|
||||||
|
allowconsumeremail: true,
|
||||||
|
allowremotedesktop: true,
|
||||||
|
allowinternetsharing: true,
|
||||||
|
pwrecoveryenabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Autodiscover {
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Public ActiveSync URL handed to devices; empty = derived from the request Host.
|
||||||
|
pub server_url: String,
|
||||||
|
/// Validate credentials against IMAP before answering (recommended).
|
||||||
|
pub require_auth: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Autodiscover {
|
||||||
|
fn default() -> Self {
|
||||||
|
Autodiscover {
|
||||||
|
enabled: true,
|
||||||
|
server_url: String::new(),
|
||||||
|
require_auth: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Security {
|
||||||
|
/// Failed logins per IP / per user before lockout.
|
||||||
|
pub auth_fail_max: u32,
|
||||||
|
pub auth_fail_window_secs: u64,
|
||||||
|
pub lockout_secs: u64,
|
||||||
|
/// Proxies whose forwarded-for header is trusted (CIDR). Default: loopback.
|
||||||
|
pub trusted_proxies: Vec<String>,
|
||||||
|
pub real_ip_header: String,
|
||||||
|
/// If non-empty, only these client networks may use ActiveSync (CIDR).
|
||||||
|
pub eas_allowed_networks: Vec<String>,
|
||||||
|
/// Users that may never sync (lowercase logins).
|
||||||
|
pub blocked_users: Vec<String>,
|
||||||
|
/// If non-empty, only these users may sync.
|
||||||
|
pub allowed_users: Vec<String>,
|
||||||
|
/// New devices need administrator approval before they can sync.
|
||||||
|
pub require_device_approval: bool,
|
||||||
|
/// Device types (case-insensitive substring of DeviceType or User-Agent) that are refused.
|
||||||
|
pub blocked_device_types: Vec<String>,
|
||||||
|
/// LOGAUTHFAIL - log failed logins (fail2ban friendly line).
|
||||||
|
pub log_auth_failures: bool,
|
||||||
|
/// Successful credentials are cached (HMAC only, never plaintext) for this many seconds.
|
||||||
|
pub auth_cache_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Security {
|
||||||
|
fn default() -> Self {
|
||||||
|
Security {
|
||||||
|
auth_fail_max: 8,
|
||||||
|
auth_fail_window_secs: 600,
|
||||||
|
lockout_secs: 900,
|
||||||
|
trusted_proxies: vec!["127.0.0.1/32".into(), "::1/128".into()],
|
||||||
|
real_ip_header: "X-Forwarded-For".into(),
|
||||||
|
eas_allowed_networks: vec![],
|
||||||
|
blocked_users: vec![],
|
||||||
|
allowed_users: vec![],
|
||||||
|
require_device_approval: false,
|
||||||
|
blocked_device_types: vec![],
|
||||||
|
log_auth_failures: true,
|
||||||
|
auth_cache_secs: 300,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Logging {
|
||||||
|
/// Log decoded WBXML of requests and responses (debug only; contains mail data!).
|
||||||
|
pub log_wbxml: bool,
|
||||||
|
/// Users with verbose (WBXML) logging regardless of global setting - LOGUSERLEVEL.
|
||||||
|
pub debug_users: Vec<String>,
|
||||||
|
/// Lines kept in memory for the panel log viewer.
|
||||||
|
pub ring_size: usize,
|
||||||
|
/// Days of hourly statistics kept in the database.
|
||||||
|
pub stats_retention_days: u32,
|
||||||
|
/// Days of audit log kept.
|
||||||
|
pub audit_retention_days: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Logging {
|
||||||
|
fn default() -> Self {
|
||||||
|
Logging {
|
||||||
|
log_wbxml: false,
|
||||||
|
debug_users: vec![],
|
||||||
|
ring_size: 2000,
|
||||||
|
stats_retention_days: 90,
|
||||||
|
audit_retention_days: 365,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Panel {
|
||||||
|
pub session_ttl_minutes: u64,
|
||||||
|
/// Bearer token for /metrics (Prometheus). Empty = metrics endpoint disabled. Secret.
|
||||||
|
pub prometheus_token: String,
|
||||||
|
/// Remove devices not seen for this many days (0 = never) - like `z-push-admin -a fixstates`.
|
||||||
|
pub device_inactive_days: u32,
|
||||||
|
/// If non-empty, the panel only answers these client networks (CIDR).
|
||||||
|
pub allowed_networks: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Panel {
|
||||||
|
fn default() -> Self {
|
||||||
|
Panel {
|
||||||
|
session_ttl_minutes: 480,
|
||||||
|
prometheus_token: String::new(),
|
||||||
|
device_inactive_days: 180,
|
||||||
|
allowed_networks: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON pointers of secret values. They are blanked on read and kept when an update sends "".
|
||||||
|
pub const SECRET_FIELDS: &[&str] = &["/smtp/password", "/panel/prometheus_token"];
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if self.imap.host.trim().is_empty() {
|
||||||
|
return Err("IMAP host is required".into());
|
||||||
|
}
|
||||||
|
if self.imap.port == 0 {
|
||||||
|
return Err("IMAP port is invalid".into());
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.general
|
||||||
|
.default_timezone
|
||||||
|
.parse::<chrono_tz::Tz>()
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"Unknown time zone '{}'",
|
||||||
|
self.general.default_timezone
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !["2.5", "12.0", "12.1", "14.0", "14.1"].contains(&self.general.max_protocol.as_str()) {
|
||||||
|
return Err("max_protocol must be one of 2.5, 12.0, 12.1, 14.0, 14.1".into());
|
||||||
|
}
|
||||||
|
for (name, d) in [
|
||||||
|
("CalDAV", &self.caldav.base_url),
|
||||||
|
("CardDAV", &self.carddav.base_url),
|
||||||
|
] {
|
||||||
|
if !d.is_empty() && url::Url::parse(d).is_err() {
|
||||||
|
return Err(format!("{name} base URL is invalid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for r in [
|
||||||
|
&self.caldav.include_regex,
|
||||||
|
&self.caldav.exclude_regex,
|
||||||
|
&self.carddav.include_regex,
|
||||||
|
&self.carddav.exclude_regex,
|
||||||
|
] {
|
||||||
|
if !r.is_empty() && regex::Regex::new(r).is_err() {
|
||||||
|
return Err(format!("Invalid regular expression: {r}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for n in self
|
||||||
|
.security
|
||||||
|
.trusted_proxies
|
||||||
|
.iter()
|
||||||
|
.chain(self.security.eas_allowed_networks.iter())
|
||||||
|
.chain(self.panel.allowed_networks.iter())
|
||||||
|
{
|
||||||
|
if crate::util::Cidr::parse(n).is_none() {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid network '{n}' (expected CIDR, e.g. 10.0.0.0/8)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.provisioning.policies.is_empty() {
|
||||||
|
return Err("At least one policy is required".into());
|
||||||
|
}
|
||||||
|
if !self
|
||||||
|
.provisioning
|
||||||
|
.policies
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.name == self.provisioning.default_policy)
|
||||||
|
{
|
||||||
|
return Err("Default policy does not exist".into());
|
||||||
|
}
|
||||||
|
if self.sync.max_items == 0 || self.sync.max_items > 2000 {
|
||||||
|
return Err("sync.max_items must be 1..2000".into());
|
||||||
|
}
|
||||||
|
if self.ping.check_interval_secs < 5 {
|
||||||
|
return Err("ping.check_interval_secs must be >= 5".into());
|
||||||
|
}
|
||||||
|
if self.ping.lifetime_min > self.ping.lifetime_max {
|
||||||
|
return Err("ping lifetime min > max".into());
|
||||||
|
}
|
||||||
|
if self.smtp.method == SendMethod::Sendmail
|
||||||
|
&& !std::path::Path::new(&self.smtp.sendmail_path).is_absolute()
|
||||||
|
{
|
||||||
|
return Err("sendmail_path must be absolute".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn policy_for(&self, user: &str) -> &Policy {
|
||||||
|
let name = self
|
||||||
|
.provisioning
|
||||||
|
.user_policies
|
||||||
|
.iter()
|
||||||
|
.find(|u| u.username.eq_ignore_ascii_case(user))
|
||||||
|
.map(|u| u.policy.as_str())
|
||||||
|
.unwrap_or(&self.provisioning.default_policy);
|
||||||
|
self.provisioning
|
||||||
|
.policies
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name == name)
|
||||||
|
.or_else(|| self.provisioning.policies.first())
|
||||||
|
.expect("validated: at least one policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timezone(&self) -> chrono_tz::Tz {
|
||||||
|
self.general
|
||||||
|
.default_timezone
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(chrono_tz::Europe::Warsaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove secrets for display.
|
||||||
|
pub fn redacted(&self) -> serde_json::Value {
|
||||||
|
let mut v = serde_json::to_value(self).unwrap_or_default();
|
||||||
|
for p in SECRET_FIELDS {
|
||||||
|
if let Some(x) = v.pointer_mut(p) {
|
||||||
|
let set = x.as_str().map(|s| !s.is_empty()).unwrap_or(false);
|
||||||
|
*x = serde_json::Value::String(if set { "__SET__".into() } else { String::new() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge an update from the panel: secret fields that are "__SET__" or missing keep the old value.
|
||||||
|
pub fn merge_update(&self, incoming: serde_json::Value) -> Result<Settings, String> {
|
||||||
|
let old = serde_json::to_value(self).map_err(|e| e.to_string())?;
|
||||||
|
// deep-merge: a partial document only changes the keys it contains
|
||||||
|
let mut incoming = {
|
||||||
|
let mut base = old.clone();
|
||||||
|
json_merge(&mut base, incoming);
|
||||||
|
base
|
||||||
|
};
|
||||||
|
for p in SECRET_FIELDS {
|
||||||
|
let keep = match incoming.pointer(p) {
|
||||||
|
None => true,
|
||||||
|
Some(v) => v.as_str() == Some("__SET__"),
|
||||||
|
};
|
||||||
|
if keep {
|
||||||
|
if let (Some(dst), Some(src)) = (incoming.pointer_mut(p), old.pointer(p)) {
|
||||||
|
*dst = src.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let s: Settings =
|
||||||
|
serde_json::from_value(incoming).map_err(|e| format!("Invalid settings: {e}"))?;
|
||||||
|
s.validate()?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_merge(base: &mut serde_json::Value, patch: serde_json::Value) {
|
||||||
|
match (base, patch) {
|
||||||
|
(serde_json::Value::Object(b), serde_json::Value::Object(p)) => {
|
||||||
|
for (k, v) in p {
|
||||||
|
json_merge(b.entry(k).or_insert(serde_json::Value::Null), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(b, p) => *b = p,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expand_user_template(tpl: &str, login: &str, default_domain: &str) -> String {
|
||||||
|
let (local, domain) = match login.split_once('@') {
|
||||||
|
Some((l, d)) => (l.to_string(), d.to_string()),
|
||||||
|
None => (login.to_string(), default_domain.to_string()),
|
||||||
|
};
|
||||||
|
tpl.replace("%u", login)
|
||||||
|
.replace("%l", &local)
|
||||||
|
.replace("%d", &domain)
|
||||||
|
.replace("{login}", login)
|
||||||
|
.replace("{local}", &local)
|
||||||
|
.replace("{domain}", &domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_validate() {
|
||||||
|
Settings::default().validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secrets_kept() {
|
||||||
|
let mut s = Settings::default();
|
||||||
|
s.smtp.password = "secret".into();
|
||||||
|
let red = s.redacted();
|
||||||
|
assert_eq!(red.pointer("/smtp/password").unwrap(), "__SET__");
|
||||||
|
let merged = s.merge_update(red).unwrap();
|
||||||
|
assert_eq!(merged.smtp.password, "secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template() {
|
||||||
|
assert_eq!(
|
||||||
|
expand_user_template("/dav/%u/", "a@b.pl", "x"),
|
||||||
|
"/dav/a@b.pl/"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
expand_user_template("{local}@{domain}", "jan", "firma.pl"),
|
||||||
|
"jan@firma.pl"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
//! SQLite persistence (WAL). All queries are parameterised.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
||||||
|
use sqlx::{FromRow, Row, SqlitePool};
|
||||||
|
|
||||||
|
use crate::config::Settings;
|
||||||
|
use crate::util::now;
|
||||||
|
|
||||||
|
pub type Db = SqlitePool;
|
||||||
|
|
||||||
|
pub async fn open(data_dir: &Path) -> Result<Db> {
|
||||||
|
std::fs::create_dir_all(data_dir).with_context(|| format!("create {}", data_dir.display()))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700));
|
||||||
|
}
|
||||||
|
let path = data_dir.join("active-push.db");
|
||||||
|
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))?
|
||||||
|
.create_if_missing(true)
|
||||||
|
.journal_mode(SqliteJournalMode::Wal)
|
||||||
|
.synchronous(SqliteSynchronous::Normal)
|
||||||
|
.foreign_keys(true)
|
||||||
|
.busy_timeout(Duration::from_secs(15))
|
||||||
|
// new databases release freed pages incrementally (see housekeeping)
|
||||||
|
.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental);
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(8)
|
||||||
|
.connect_with(opts)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("open database {}", path.display()))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
for f in ["active-push.db", "active-push.db-wal", "active-push.db-shm"] {
|
||||||
|
let _ =
|
||||||
|
std::fs::set_permissions(data_dir.join(f), std::fs::Permissions::from_mode(0o600));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ settings
|
||||||
|
|
||||||
|
pub async fn load_settings(db: &Db) -> Result<(Settings, i64)> {
|
||||||
|
let row = sqlx::query("SELECT json, revision FROM settings WHERE id = 1")
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
match row {
|
||||||
|
Some(r) => {
|
||||||
|
let json: String = r.get(0);
|
||||||
|
let rev: i64 = r.get(1);
|
||||||
|
let s: Settings = serde_json::from_str(&json).context("stored settings are invalid")?;
|
||||||
|
Ok((s, rev))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let s = Settings::default();
|
||||||
|
save_settings(db, &s).await?;
|
||||||
|
Ok((s, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_settings(db: &Db, s: &Settings) -> Result<i64> {
|
||||||
|
let json = serde_json::to_string(s)?;
|
||||||
|
let r = sqlx::query(
|
||||||
|
"INSERT INTO settings (id, json, revision, updated_at) VALUES (1, ?, 1, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET json = excluded.json, revision = settings.revision + 1, updated_at = excluded.updated_at
|
||||||
|
RETURNING revision",
|
||||||
|
)
|
||||||
|
.bind(json)
|
||||||
|
.bind(now())
|
||||||
|
.fetch_one(db)
|
||||||
|
.await?;
|
||||||
|
Ok(r.get(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ devices
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize, Default)]
|
||||||
|
pub struct Device {
|
||||||
|
pub id: i64,
|
||||||
|
pub device_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub device_type: String,
|
||||||
|
pub user_agent: String,
|
||||||
|
pub model: String,
|
||||||
|
pub imei: String,
|
||||||
|
pub friendly_name: String,
|
||||||
|
pub os: String,
|
||||||
|
pub os_language: String,
|
||||||
|
pub phone_number: String,
|
||||||
|
pub mobile_operator: String,
|
||||||
|
pub as_version: String,
|
||||||
|
pub policy_key: i64,
|
||||||
|
pub policy_temp_key: i64,
|
||||||
|
pub policy_name: String,
|
||||||
|
pub policy_hash: String,
|
||||||
|
pub wipe_status: i64,
|
||||||
|
pub wipe_requested_at: Option<i64>,
|
||||||
|
pub wipe_requested_by: Option<String>,
|
||||||
|
pub wipe_completed_at: Option<i64>,
|
||||||
|
pub blocked: i64,
|
||||||
|
pub approved: i64,
|
||||||
|
pub first_seen: i64,
|
||||||
|
pub last_seen: i64,
|
||||||
|
pub last_ip: String,
|
||||||
|
pub last_command: String,
|
||||||
|
pub requests: i64,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub recovery_password: String,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub ping_folders: String,
|
||||||
|
pub ping_lifetime: i64,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub last_sync_request: String,
|
||||||
|
pub notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wipe status values (Z-Push SYNC_PROVISION_RWSTATUS_*).
|
||||||
|
pub const WIPE_NA: i64 = 0;
|
||||||
|
pub const WIPE_OK: i64 = 1;
|
||||||
|
pub const WIPE_PENDING: i64 = 2;
|
||||||
|
pub const WIPE_REQUESTED: i64 = 4;
|
||||||
|
pub const WIPE_WIPED: i64 = 8;
|
||||||
|
pub const WIPE_ACCOUNT_PENDING: i64 = 16;
|
||||||
|
pub const WIPE_ACCOUNT_REQUESTED: i64 = 32;
|
||||||
|
pub const WIPE_ACCOUNT_WIPED: i64 = 64;
|
||||||
|
|
||||||
|
pub async fn device_get_or_create(
|
||||||
|
db: &Db,
|
||||||
|
device_id: &str,
|
||||||
|
username: &str,
|
||||||
|
device_type: &str,
|
||||||
|
user_agent: &str,
|
||||||
|
approved_default: bool,
|
||||||
|
) -> Result<(Device, bool)> {
|
||||||
|
if let Some(d) = device_get(db, device_id, username).await? {
|
||||||
|
return Ok((d, false));
|
||||||
|
}
|
||||||
|
let ts = now();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO devices (device_id, username, device_type, user_agent, first_seen, last_seen, approved)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(device_id)
|
||||||
|
.bind(username)
|
||||||
|
.bind(device_type)
|
||||||
|
.bind(user_agent)
|
||||||
|
.bind(ts)
|
||||||
|
.bind(ts)
|
||||||
|
.bind(approved_default as i64)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
let d = device_get(db, device_id, username)
|
||||||
|
.await?
|
||||||
|
.context("device insert failed")?;
|
||||||
|
Ok((d, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_get(db: &Db, device_id: &str, username: &str) -> Result<Option<Device>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE device_id = ? AND username = ?")
|
||||||
|
.bind(device_id)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_by_id(db: &Db, id: i64) -> Result<Option<Device>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_list(db: &Db) -> Result<Vec<Device>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as::<_, Device>("SELECT * FROM devices ORDER BY last_seen DESC")
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_touch(
|
||||||
|
db: &Db,
|
||||||
|
id: i64,
|
||||||
|
ip: &str,
|
||||||
|
command: &str,
|
||||||
|
as_version: &str,
|
||||||
|
user_agent: &str,
|
||||||
|
device_type: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE devices SET last_seen = ?, last_ip = ?, last_command = ?, requests = requests + 1,
|
||||||
|
as_version = CASE WHEN ? = '' THEN as_version ELSE ? END,
|
||||||
|
user_agent = CASE WHEN ? = '' THEN user_agent ELSE ? END,
|
||||||
|
device_type = CASE WHEN ? = '' THEN device_type ELSE ? END
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(now())
|
||||||
|
.bind(ip)
|
||||||
|
.bind(command)
|
||||||
|
.bind(as_version)
|
||||||
|
.bind(as_version)
|
||||||
|
.bind(user_agent)
|
||||||
|
.bind(user_agent)
|
||||||
|
.bind(device_type)
|
||||||
|
.bind(device_type)
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct DeviceInfo {
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub imei: Option<String>,
|
||||||
|
pub friendly_name: Option<String>,
|
||||||
|
pub os: Option<String>,
|
||||||
|
pub os_language: Option<String>,
|
||||||
|
pub phone_number: Option<String>,
|
||||||
|
pub mobile_operator: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_set_info(db: &Db, id: i64, i: &DeviceInfo) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE devices SET model = COALESCE(?, model), imei = COALESCE(?, imei), friendly_name = COALESCE(?, friendly_name),
|
||||||
|
os = COALESCE(?, os), os_language = COALESCE(?, os_language), phone_number = COALESCE(?, phone_number),
|
||||||
|
mobile_operator = COALESCE(?, mobile_operator), user_agent = COALESCE(?, user_agent) WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(&i.model)
|
||||||
|
.bind(&i.imei)
|
||||||
|
.bind(&i.friendly_name)
|
||||||
|
.bind(&i.os)
|
||||||
|
.bind(&i.os_language)
|
||||||
|
.bind(&i.phone_number)
|
||||||
|
.bind(&i.mobile_operator)
|
||||||
|
.bind(&i.user_agent)
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_set_field(
|
||||||
|
db: &Db,
|
||||||
|
id: i64,
|
||||||
|
field: DeviceField,
|
||||||
|
value: Option<i64>,
|
||||||
|
text: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let col = match field {
|
||||||
|
DeviceField::PolicyKey => "policy_key",
|
||||||
|
DeviceField::PolicyTempKey => "policy_temp_key",
|
||||||
|
DeviceField::WipeStatus => "wipe_status",
|
||||||
|
DeviceField::Blocked => "blocked",
|
||||||
|
DeviceField::Approved => "approved",
|
||||||
|
DeviceField::PingLifetime => "ping_lifetime",
|
||||||
|
DeviceField::PolicyName => "policy_name",
|
||||||
|
DeviceField::PolicyHash => "policy_hash",
|
||||||
|
DeviceField::RecoveryPassword => "recovery_password",
|
||||||
|
DeviceField::PingFolders => "ping_folders",
|
||||||
|
DeviceField::LastSyncRequest => "last_sync_request",
|
||||||
|
DeviceField::Notes => "notes",
|
||||||
|
};
|
||||||
|
let sql = format!("UPDATE devices SET {col} = ? WHERE id = ?");
|
||||||
|
let q = sqlx::query(&sql);
|
||||||
|
let q = match (value, text) {
|
||||||
|
(Some(v), _) => q.bind(v),
|
||||||
|
(None, Some(t)) => q.bind(t.to_string()),
|
||||||
|
(None, None) => q.bind(Option::<String>::None),
|
||||||
|
};
|
||||||
|
q.bind(id).execute(db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum DeviceField {
|
||||||
|
PolicyKey,
|
||||||
|
PolicyTempKey,
|
||||||
|
WipeStatus,
|
||||||
|
Blocked,
|
||||||
|
Approved,
|
||||||
|
PingLifetime,
|
||||||
|
PolicyName,
|
||||||
|
PolicyHash,
|
||||||
|
RecoveryPassword,
|
||||||
|
PingFolders,
|
||||||
|
LastSyncRequest,
|
||||||
|
Notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_request_wipe(db: &Db, id: i64, by: &str, account_only: bool) -> Result<()> {
|
||||||
|
let st = if account_only {
|
||||||
|
WIPE_ACCOUNT_PENDING
|
||||||
|
} else {
|
||||||
|
WIPE_PENDING
|
||||||
|
};
|
||||||
|
sqlx::query("UPDATE devices SET wipe_status = ?, wipe_requested_at = ?, wipe_requested_by = ?, wipe_completed_at = NULL WHERE id = ?")
|
||||||
|
.bind(st)
|
||||||
|
.bind(now())
|
||||||
|
.bind(by)
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_wipe_done(db: &Db, id: i64, status: i64) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE devices SET wipe_status = ?, wipe_completed_at = ? WHERE id = ?")
|
||||||
|
.bind(status)
|
||||||
|
.bind(now())
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_delete(db: &Db, id: i64) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM devices WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_reset_states(db: &Db, id: i64) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM sync_states WHERE device_rowid = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM folder_stats WHERE device_rowid = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("UPDATE devices SET ping_folders = '', last_sync_request = '' WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn devices_cleanup_inactive(db: &Db, days: u32) -> Result<u64> {
|
||||||
|
if days == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let cutoff = now() - days as i64 * 86400;
|
||||||
|
let r = sqlx::query(
|
||||||
|
"DELETE FROM devices WHERE last_seen < ? AND wipe_status NOT IN (2, 4, 16, 32)",
|
||||||
|
)
|
||||||
|
.bind(cutoff)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(r.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ states
|
||||||
|
|
||||||
|
pub async fn state_get(
|
||||||
|
db: &Db,
|
||||||
|
dev: i64,
|
||||||
|
collection: &str,
|
||||||
|
uuid: &str,
|
||||||
|
counter: i64,
|
||||||
|
) -> Result<Option<Vec<u8>>> {
|
||||||
|
let r = sqlx::query(
|
||||||
|
"SELECT data FROM sync_states WHERE device_rowid = ? AND collection = ? AND uuid = ? AND counter = ?",
|
||||||
|
)
|
||||||
|
.bind(dev)
|
||||||
|
.bind(collection)
|
||||||
|
.bind(uuid)
|
||||||
|
.bind(counter)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
Ok(r.map(|r| r.get(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store state `counter` and drop everything older than `counter - 1`
|
||||||
|
/// (the previous state is kept so a device may retry a lost response).
|
||||||
|
pub async fn state_put(
|
||||||
|
db: &Db,
|
||||||
|
dev: i64,
|
||||||
|
collection: &str,
|
||||||
|
uuid: &str,
|
||||||
|
counter: i64,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR REPLACE INTO sync_states (device_rowid, collection, uuid, counter, data, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(dev)
|
||||||
|
.bind(collection)
|
||||||
|
.bind(uuid)
|
||||||
|
.bind(counter)
|
||||||
|
.bind(data)
|
||||||
|
.bind(now())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM sync_states WHERE device_rowid = ? AND collection = ? AND (uuid != ? OR counter < ?)",
|
||||||
|
)
|
||||||
|
.bind(dev)
|
||||||
|
.bind(collection)
|
||||||
|
.bind(uuid)
|
||||||
|
.bind(counter - 1)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn state_delete_collection(db: &Db, dev: i64, collection: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM sync_states WHERE device_rowid = ? AND collection = ?")
|
||||||
|
.bind(dev)
|
||||||
|
.bind(collection)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM folder_stats WHERE device_rowid = ? AND collection = ?")
|
||||||
|
.bind(dev)
|
||||||
|
.bind(collection)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latest state per collection for a device (used by Ping/GetItemEstimate when no key is given).
|
||||||
|
pub async fn state_latest(
|
||||||
|
db: &Db,
|
||||||
|
dev: i64,
|
||||||
|
collection: &str,
|
||||||
|
) -> Result<Option<(String, i64, Vec<u8>)>> {
|
||||||
|
let r = sqlx::query(
|
||||||
|
"SELECT uuid, counter, data FROM sync_states WHERE device_rowid = ? AND collection = ? ORDER BY created_at DESC, counter DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(dev)
|
||||||
|
.bind(collection)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
Ok(r.map(|r| (r.get(0), r.get(1), r.get(2))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn state_collections(db: &Db, dev: i64) -> Result<Vec<String>> {
|
||||||
|
let rows = sqlx::query("SELECT DISTINCT collection FROM sync_states WHERE device_rowid = ?")
|
||||||
|
.bind(dev)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|r| r.get(0)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ folder / item maps
|
||||||
|
|
||||||
|
pub async fn folder_short_id(db: &Db, user: &str, backend_id: &str) -> Result<i64> {
|
||||||
|
if let Some(r) =
|
||||||
|
sqlx::query("SELECT short_id FROM folder_map WHERE username = ? AND backend_id = ?")
|
||||||
|
.bind(user)
|
||||||
|
.bind(backend_id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(r.get(0));
|
||||||
|
}
|
||||||
|
let r = sqlx::query(
|
||||||
|
"INSERT INTO folder_map (username, backend_id, short_id)
|
||||||
|
VALUES (?, ?, (SELECT COALESCE(MAX(short_id), 0) + 1 FROM folder_map WHERE username = ?))
|
||||||
|
ON CONFLICT(username, backend_id) DO UPDATE SET short_id = folder_map.short_id
|
||||||
|
RETURNING short_id",
|
||||||
|
)
|
||||||
|
.bind(user)
|
||||||
|
.bind(backend_id)
|
||||||
|
.bind(user)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await?;
|
||||||
|
Ok(r.get(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn folder_backend_id(db: &Db, user: &str, short_id: &str) -> Result<Option<String>> {
|
||||||
|
let Ok(n) = short_id.parse::<i64>() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let r = sqlx::query("SELECT backend_id FROM folder_map WHERE username = ? AND short_id = ?")
|
||||||
|
.bind(user)
|
||||||
|
.bind(n)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
Ok(r.map(|r| r.get(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn folder_rename_backend(db: &Db, user: &str, old: &str, new: &str) -> Result<()> {
|
||||||
|
// a stale mapping of a previously deleted folder with the new name must go
|
||||||
|
sqlx::query("DELETE FROM folder_map WHERE username = ? AND backend_id = ?")
|
||||||
|
.bind(user)
|
||||||
|
.bind(new)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("UPDATE folder_map SET backend_id = ? WHERE username = ? AND backend_id = ?")
|
||||||
|
.bind(new)
|
||||||
|
.bind(user)
|
||||||
|
.bind(old)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn item_short(db: &Db, user: &str, folder: &str, long_id: &str) -> Result<i64> {
|
||||||
|
if let Some(r) = sqlx::query(
|
||||||
|
"SELECT short_id FROM item_map WHERE username = ? AND folder = ? AND long_id = ?",
|
||||||
|
)
|
||||||
|
.bind(user)
|
||||||
|
.bind(folder)
|
||||||
|
.bind(long_id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(r.get(0));
|
||||||
|
}
|
||||||
|
let r = sqlx::query(
|
||||||
|
"INSERT INTO item_map (username, folder, long_id, short_id)
|
||||||
|
VALUES (?, ?, ?, (SELECT COALESCE(MAX(short_id), 0) + 1 FROM item_map WHERE username = ? AND folder = ?))
|
||||||
|
ON CONFLICT(username, folder, long_id) DO UPDATE SET short_id = item_map.short_id
|
||||||
|
RETURNING short_id",
|
||||||
|
)
|
||||||
|
.bind(user)
|
||||||
|
.bind(folder)
|
||||||
|
.bind(long_id)
|
||||||
|
.bind(user)
|
||||||
|
.bind(folder)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await?;
|
||||||
|
Ok(r.get(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn item_long(db: &Db, user: &str, folder: &str, short: i64) -> Result<Option<String>> {
|
||||||
|
let r = sqlx::query(
|
||||||
|
"SELECT long_id FROM item_map WHERE username = ? AND folder = ? AND short_id = ?",
|
||||||
|
)
|
||||||
|
.bind(user)
|
||||||
|
.bind(folder)
|
||||||
|
.bind(short)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
Ok(r.map(|r| r.get(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ folder stats
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
pub struct FolderStat {
|
||||||
|
pub collection: String,
|
||||||
|
pub folder_name: String,
|
||||||
|
pub folder_class: String,
|
||||||
|
pub last_sync: i64,
|
||||||
|
pub items_out: i64,
|
||||||
|
pub items_in: i64,
|
||||||
|
pub item_count: i64,
|
||||||
|
pub last_status: i64,
|
||||||
|
pub window_size: i64,
|
||||||
|
pub loop_hits: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn folder_stat_update(
|
||||||
|
db: &Db,
|
||||||
|
dev: i64,
|
||||||
|
collection: &str,
|
||||||
|
name: &str,
|
||||||
|
class: &str,
|
||||||
|
out: i64,
|
||||||
|
inn: i64,
|
||||||
|
count: i64,
|
||||||
|
status: i64,
|
||||||
|
window: i64,
|
||||||
|
loop_hit: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO folder_stats (device_rowid, collection, folder_name, folder_class, last_sync, items_out, items_in, item_count, last_status, window_size, loop_hits)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(device_rowid, collection) DO UPDATE SET
|
||||||
|
folder_name = CASE WHEN excluded.folder_name = '' THEN folder_name ELSE excluded.folder_name END,
|
||||||
|
folder_class = CASE WHEN excluded.folder_class = '' THEN folder_class ELSE excluded.folder_class END,
|
||||||
|
last_sync = excluded.last_sync, items_out = items_out + excluded.items_out, items_in = items_in + excluded.items_in,
|
||||||
|
item_count = excluded.item_count, last_status = excluded.last_status, window_size = excluded.window_size,
|
||||||
|
loop_hits = loop_hits + excluded.loop_hits",
|
||||||
|
)
|
||||||
|
.bind(dev)
|
||||||
|
.bind(collection)
|
||||||
|
.bind(name)
|
||||||
|
.bind(class)
|
||||||
|
.bind(now())
|
||||||
|
.bind(out)
|
||||||
|
.bind(inn)
|
||||||
|
.bind(count)
|
||||||
|
.bind(status)
|
||||||
|
.bind(window)
|
||||||
|
.bind(loop_hit as i64)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn folder_stats(db: &Db, dev: i64) -> Result<Vec<FolderStat>> {
|
||||||
|
Ok(sqlx::query_as::<_, FolderStat>(
|
||||||
|
"SELECT collection, folder_name, folder_class, last_sync, items_out, items_in, item_count, last_status, window_size, loop_hits
|
||||||
|
FROM folder_stats WHERE device_rowid = ? ORDER BY folder_class, folder_name",
|
||||||
|
)
|
||||||
|
.bind(dev)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ audit
|
||||||
|
|
||||||
|
pub async fn audit(db: &Db, actor: &str, action: &str, target: &str, ip: &str, details: &str) {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO audit_log (ts, actor, action, target, ip, details) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(now())
|
||||||
|
.bind(actor)
|
||||||
|
.bind(action)
|
||||||
|
.bind(target)
|
||||||
|
.bind(ip)
|
||||||
|
.bind(details)
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
pub struct AuditEntry {
|
||||||
|
pub id: i64,
|
||||||
|
pub ts: i64,
|
||||||
|
pub actor: String,
|
||||||
|
pub action: String,
|
||||||
|
pub target: String,
|
||||||
|
pub ip: String,
|
||||||
|
pub details: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn audit_list(db: &Db, limit: i64) -> Result<Vec<AuditEntry>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as::<_, AuditEntry>("SELECT * FROM audit_log ORDER BY id DESC LIMIT ?")
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn housekeeping(db: &Db, s: &Settings) -> Result<()> {
|
||||||
|
let ts = now();
|
||||||
|
sqlx::query("DELETE FROM admin_sessions WHERE expires_at < ?")
|
||||||
|
.bind(ts)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM setup_token WHERE expires_at < ?")
|
||||||
|
.bind(ts)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM stats_hourly WHERE hour < ?")
|
||||||
|
.bind(ts - s.logging.stats_retention_days as i64 * 86400)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM audit_log WHERE ts < ?")
|
||||||
|
.bind(ts - s.logging.audit_retention_days as i64 * 86400)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
let removed = devices_cleanup_inactive(db, s.panel.device_inactive_days).await?;
|
||||||
|
if removed > 0 {
|
||||||
|
tracing::info!(removed, "removed inactive devices");
|
||||||
|
}
|
||||||
|
// id maps of accounts that no longer have any device
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM item_map WHERE username NOT IN (SELECT DISTINCT username FROM devices)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM folder_map WHERE username NOT IN (SELECT DISTINCT username FROM devices)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
// states of collections a device stopped syncing more than a year ago
|
||||||
|
sqlx::query("DELETE FROM sync_states WHERE created_at < ?")
|
||||||
|
.bind(ts - 400 * 86400)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
compact(db).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the database files small: checkpoint/truncate the WAL and give free pages back.
|
||||||
|
pub async fn compact(db: &Db) {
|
||||||
|
let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
|
||||||
|
.fetch_one(db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
let free: i64 = sqlx::query_scalar("PRAGMA freelist_count")
|
||||||
|
.fetch_one(db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
let pages: i64 = sqlx::query_scalar("PRAGMA page_count")
|
||||||
|
.fetch_one(db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(1);
|
||||||
|
if mode == 0 {
|
||||||
|
// databases created by 0.1.0: switch to incremental vacuum once (one full VACUUM)
|
||||||
|
tracing::info!("converting database to incremental auto-vacuum");
|
||||||
|
if let Ok(mut c) = db.acquire().await {
|
||||||
|
let _ = sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
|
||||||
|
.execute(&mut *c)
|
||||||
|
.await;
|
||||||
|
let _ = sqlx::query("VACUUM").execute(&mut *c).await;
|
||||||
|
}
|
||||||
|
} else if free > 256 && free * 5 > pages {
|
||||||
|
let _ = sqlx::query("PRAGMA incremental_vacuum").execute(db).await;
|
||||||
|
}
|
||||||
|
let _ = sqlx::query("PRAGMA optimize").execute(db).await;
|
||||||
|
let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
//! vCard <-> EAS Contacts (with lossless merge of unmapped vCard data).
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use chrono::{NaiveDate, TimeZone, Utc};
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
|
||||||
|
use super::{categories, categories_element, incoming_body, pim_body, Options};
|
||||||
|
use crate::backend::ical::{self, Component, Property};
|
||||||
|
use crate::backend::tz;
|
||||||
|
use crate::wbxml::Element;
|
||||||
|
|
||||||
|
/// Phone slots: (EAS tag, required types, excluded types)
|
||||||
|
const PHONE_SLOTS: &[(&str, &[&str], &[&str])] = &[
|
||||||
|
("Contacts:MobilePhoneNumber", &["cell"], &[]),
|
||||||
|
("Contacts:BusinessFaxNumber", &["work", "fax"], &[]),
|
||||||
|
("Contacts:HomeFaxNumber", &["fax"], &["work"]),
|
||||||
|
("Contacts:PagerNumber", &["pager"], &[]),
|
||||||
|
("Contacts:CarPhoneNumber", &["car"], &[]),
|
||||||
|
(
|
||||||
|
"Contacts:BusinessPhoneNumber",
|
||||||
|
&["work"],
|
||||||
|
&["fax", "cell", "pager"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Contacts:Business2PhoneNumber",
|
||||||
|
&["work"],
|
||||||
|
&["fax", "cell", "pager"],
|
||||||
|
),
|
||||||
|
("Contacts:HomePhoneNumber", &["home"], &["fax", "cell"]),
|
||||||
|
("Contacts:Home2PhoneNumber", &["home"], &["fax", "cell"]),
|
||||||
|
("Contacts:RadioPhoneNumber", &["x-radio"], &[]),
|
||||||
|
("Contacts:AssistantPhoneNumber", &["x-assistant"], &[]),
|
||||||
|
("Contacts2:CompanyMainPhone", &["x-company"], &[]),
|
||||||
|
("Contacts2:MMS", &["x-mms"], &[]),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn tel_types(slot: &str) -> &'static str {
|
||||||
|
match slot {
|
||||||
|
"Contacts:MobilePhoneNumber" => "CELL",
|
||||||
|
"Contacts:BusinessFaxNumber" => "WORK,FAX",
|
||||||
|
"Contacts:HomeFaxNumber" => "HOME,FAX",
|
||||||
|
"Contacts:PagerNumber" => "PAGER",
|
||||||
|
"Contacts:CarPhoneNumber" => "CAR",
|
||||||
|
"Contacts:BusinessPhoneNumber" | "Contacts:Business2PhoneNumber" => "WORK,VOICE",
|
||||||
|
"Contacts:HomePhoneNumber" | "Contacts:Home2PhoneNumber" => "HOME,VOICE",
|
||||||
|
"Contacts:RadioPhoneNumber" => "X-RADIO",
|
||||||
|
"Contacts:AssistantPhoneNumber" => "X-ASSISTANT",
|
||||||
|
"Contacts2:CompanyMainPhone" => "X-COMPANY",
|
||||||
|
"Contacts2:MMS" => "X-MMS",
|
||||||
|
_ => "VOICE",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign TEL properties to EAS slots. Returns (slot -> prop index) and leftover indices.
|
||||||
|
fn assign_tels(tels: &[&Property]) -> (Vec<(&'static str, usize)>, Vec<usize>) {
|
||||||
|
let mut used = vec![false; tels.len()];
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (slot, req, exc) in PHONE_SLOTS {
|
||||||
|
let pos = tels.iter().enumerate().position(|(i, p)| {
|
||||||
|
if used[i] {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let t = p.types();
|
||||||
|
req.iter().all(|r| t.iter().any(|x| x == r))
|
||||||
|
&& !exc.iter().any(|e| t.iter().any(|x| x == e))
|
||||||
|
});
|
||||||
|
if let Some(i) = pos {
|
||||||
|
used[i] = true;
|
||||||
|
out.push((*slot, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// untyped / voice-only numbers go to free home/business/mobile slots
|
||||||
|
for fallback in [
|
||||||
|
"Contacts:HomePhoneNumber",
|
||||||
|
"Contacts:BusinessPhoneNumber",
|
||||||
|
"Contacts:MobilePhoneNumber",
|
||||||
|
"Contacts:Home2PhoneNumber",
|
||||||
|
"Contacts:Business2PhoneNumber",
|
||||||
|
] {
|
||||||
|
if out.iter().any(|(s, _)| *s == fallback) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(i) = (0..tels.len()).find(|i| {
|
||||||
|
!used[*i] && {
|
||||||
|
let t = tels[*i].types();
|
||||||
|
t.is_empty()
|
||||||
|
|| t.iter().all(|x| {
|
||||||
|
x == "voice" || x == "pref" || x == "other" || x.starts_with("pref")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
used[i] = true;
|
||||||
|
out.push((fallback, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let left = (0..tels.len()).filter(|i| !used[*i]).collect();
|
||||||
|
(out, left)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADR_SLOTS: &[(&str, &str)] = &[("Business", "work"), ("Home", "home"), ("Other", "other")];
|
||||||
|
|
||||||
|
fn assign_adrs(adrs: &[&Property]) -> (Vec<(&'static str, usize)>, Vec<usize>) {
|
||||||
|
let mut used = vec![false; adrs.len()];
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (slot, typ) in ADR_SLOTS {
|
||||||
|
if let Some(i) =
|
||||||
|
(0..adrs.len()).find(|i| !used[*i] && adrs[*i].types().iter().any(|t| t == typ))
|
||||||
|
{
|
||||||
|
used[i] = true;
|
||||||
|
out.push((*slot, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// untyped address -> Other / Home
|
||||||
|
for (slot, _) in [("Other", ""), ("Home", "")] {
|
||||||
|
if out.iter().any(|(s, _)| *s == slot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(i) = (0..adrs.len()).find(|i| {
|
||||||
|
!used[*i]
|
||||||
|
&& adrs[*i]
|
||||||
|
.types()
|
||||||
|
.iter()
|
||||||
|
.all(|t| t == "pref" || t == "postal" || t == "parcel")
|
||||||
|
}) {
|
||||||
|
used[i] = true;
|
||||||
|
out.push((slot, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let left = (0..adrs.len()).filter(|i| !used[*i]).collect();
|
||||||
|
(out, left)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bday_to_eas(v: &str, dtz: &Tz) -> Option<String> {
|
||||||
|
let v = v.trim();
|
||||||
|
let d = if v.len() >= 10 && v.as_bytes()[4] == b'-' {
|
||||||
|
NaiveDate::parse_from_str(&v[..10], "%Y-%m-%d").ok()
|
||||||
|
} else if v.starts_with("--") {
|
||||||
|
// --MMDD (no year) -> use 1604 like Apple/Outlook
|
||||||
|
NaiveDate::parse_from_str(&format!("1604{}", &v[2..6.min(v.len())]), "%Y%m%d").ok()
|
||||||
|
} else {
|
||||||
|
NaiveDate::parse_from_str(&v[..8.min(v.len())], "%Y%m%d").ok()
|
||||||
|
}?;
|
||||||
|
let local = dtz
|
||||||
|
.from_local_datetime(&d.and_hms_opt(0, 0, 0)?)
|
||||||
|
.earliest()?;
|
||||||
|
Some(tz::fmt_iso(&local.with_timezone(&Utc)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eas_to_date(s: &str, dtz: &Tz) -> Option<NaiveDate> {
|
||||||
|
let t = tz::parse_eas_date(s)?;
|
||||||
|
Some(
|
||||||
|
(t + chrono::Duration::hours(12))
|
||||||
|
.with_timezone(dtz)
|
||||||
|
.date_naive(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_eas(vcf: &str, opts: &Options, ver: u32, dtz: Tz, max_picture: usize) -> Option<Element> {
|
||||||
|
let cards = ical::parse(vcf);
|
||||||
|
let c = cards.iter().find(|c| c.name == "VCARD")?;
|
||||||
|
let mut app = Element::new("AirSync:ApplicationData");
|
||||||
|
let get = |n: &str| c.text(n);
|
||||||
|
if let Some(b) = get("ANNIVERSARY")
|
||||||
|
.or_else(|| get("X-ANNIVERSARY"))
|
||||||
|
.and_then(|v| bday_to_eas(&v, &dtz))
|
||||||
|
{
|
||||||
|
app.push_text("Contacts:Anniversary", b);
|
||||||
|
}
|
||||||
|
app.push_opt(
|
||||||
|
"Contacts:AssistantName",
|
||||||
|
get("X-ASSISTANT").or_else(|| get("X-MS-ASSISTANT")),
|
||||||
|
);
|
||||||
|
if let Some(b) = get("BDAY").and_then(|v| bday_to_eas(&v, &dtz)) {
|
||||||
|
app.push_text("Contacts:Birthday", b);
|
||||||
|
}
|
||||||
|
let note = get("NOTE").unwrap_or_default();
|
||||||
|
if let Some(b) = pim_body(¬e, opts, ver) {
|
||||||
|
if !note.is_empty() {
|
||||||
|
app.push(b);
|
||||||
|
}
|
||||||
|
} else if !note.is_empty() {
|
||||||
|
app.push_text("Contacts:Body", note.clone());
|
||||||
|
}
|
||||||
|
// phones
|
||||||
|
let tels: Vec<&Property> = c.props_named("TEL").collect();
|
||||||
|
let (slots, _) = assign_tels(&tels);
|
||||||
|
// addresses
|
||||||
|
let adrs: Vec<&Property> = c.props_named("ADR").collect();
|
||||||
|
let (aslots, _) = assign_adrs(&adrs);
|
||||||
|
for (slot, i) in &aslots {
|
||||||
|
let parts = ical::split_structured(&adrs[*i].value);
|
||||||
|
let p = |n: usize| parts.get(n).cloned().unwrap_or_default();
|
||||||
|
let street = [p(1), p(2)]
|
||||||
|
.iter()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
for (suffix, val) in [
|
||||||
|
("City", p(3)),
|
||||||
|
("Country", p(6)),
|
||||||
|
("PostalCode", p(5)),
|
||||||
|
("State", p(4)),
|
||||||
|
("Street", street),
|
||||||
|
] {
|
||||||
|
if !val.is_empty() {
|
||||||
|
app.push_text(&format!("Contacts:{slot}{suffix}"), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (slot, i) in &slots {
|
||||||
|
if !slot.starts_with("Contacts2") {
|
||||||
|
app.push_text(slot, tels[*i].text_value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cats: Vec<String> = c
|
||||||
|
.props_named("CATEGORIES")
|
||||||
|
.flat_map(|p| ical::split_list(&p.value))
|
||||||
|
.collect();
|
||||||
|
if let Some(e) = categories_element("Contacts", &cats) {
|
||||||
|
app.push(e);
|
||||||
|
}
|
||||||
|
if let Some(org) = c.prop("ORG") {
|
||||||
|
let parts = ical::split_structured(&org.value);
|
||||||
|
if let Some(n) = parts.first().filter(|s| !s.is_empty()) {
|
||||||
|
app.push_text("Contacts:CompanyName", n.clone());
|
||||||
|
}
|
||||||
|
if let Some(d) = parts.get(1).filter(|s| !s.is_empty()) {
|
||||||
|
app.push_text("Contacts:Department", d.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, e) in c.props_named("EMAIL").take(3).enumerate() {
|
||||||
|
app.push_text(&format!("Contacts:Email{}Address", i + 1), e.text_value());
|
||||||
|
}
|
||||||
|
let n = c
|
||||||
|
.prop("N")
|
||||||
|
.map(|n| ical::split_structured(&n.value))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let np = |i: usize| n.get(i).cloned().filter(|s| !s.is_empty());
|
||||||
|
let fileas = get("X-EAS-FILEAS")
|
||||||
|
.or_else(|| get("FN"))
|
||||||
|
.or_else(|| match (np(0), np(1)) {
|
||||||
|
(Some(l), Some(f)) => Some(format!("{l}, {f}")),
|
||||||
|
(Some(l), None) => Some(l),
|
||||||
|
(None, Some(f)) => Some(f),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
app.push_opt("Contacts:FileAs", fileas);
|
||||||
|
app.push_opt("Contacts:FirstName", np(1));
|
||||||
|
app.push_opt("Contacts:JobTitle", get("TITLE"));
|
||||||
|
app.push_opt("Contacts:LastName", np(0));
|
||||||
|
app.push_opt("Contacts:MiddleName", np(2));
|
||||||
|
app.push_opt("Contacts:OfficeLocation", get("X-OFFICE-LOCATION"));
|
||||||
|
app.push_opt("Contacts:Spouse", get("X-SPOUSE"));
|
||||||
|
app.push_opt("Contacts:Suffix", np(4));
|
||||||
|
app.push_opt("Contacts:Title", np(3));
|
||||||
|
app.push_opt("Contacts:WebPage", get("URL"));
|
||||||
|
app.push_opt("Contacts:YomiCompanyName", get("X-PHONETIC-ORG"));
|
||||||
|
app.push_opt("Contacts:YomiFirstName", get("X-PHONETIC-FIRST-NAME"));
|
||||||
|
app.push_opt("Contacts:YomiLastName", get("X-PHONETIC-LAST-NAME"));
|
||||||
|
if let Some(ph) = c.prop("PHOTO") {
|
||||||
|
let v = ph.value.trim();
|
||||||
|
let data = if let Some(idx) = v.find("base64,") {
|
||||||
|
v[idx + 7..].to_string()
|
||||||
|
} else if ph
|
||||||
|
.param("ENCODING")
|
||||||
|
.map(|e| e.eq_ignore_ascii_case("b") || e.eq_ignore_ascii_case("BASE64"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| ph.params.iter().any(|(k, _)| k == "BASE64")
|
||||||
|
{
|
||||||
|
v.to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let data: String = data.chars().filter(|c| !c.is_whitespace()).collect();
|
||||||
|
if !data.is_empty() && data.len() * 3 / 4 <= max_picture {
|
||||||
|
app.push_text("Contacts:Picture", data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Contacts2
|
||||||
|
app.push_opt("Contacts2:CustomerId", get("X-CUSTOMER-ID"));
|
||||||
|
app.push_opt("Contacts2:GovernmentId", get("X-GOVERNMENT-ID"));
|
||||||
|
for (i, im) in c.props_named("IMPP").take(3).enumerate() {
|
||||||
|
let tag = if i == 0 {
|
||||||
|
"Contacts2:IMAddress".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Contacts2:IMAddress{}", i + 1)
|
||||||
|
};
|
||||||
|
app.push_text(&tag, im.text_value());
|
||||||
|
}
|
||||||
|
app.push_opt("Contacts2:ManagerName", get("X-MANAGER"));
|
||||||
|
for (slot, i) in &slots {
|
||||||
|
if slot.starts_with("Contacts2") {
|
||||||
|
app.push_text(slot, tels[*i].text_value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.push_opt("Contacts2:AccountName", get("X-ACCOUNT-NAME"));
|
||||||
|
app.push_opt("Contacts2:NickName", get("NICKNAME"));
|
||||||
|
if ver >= 120 {
|
||||||
|
app.push_text("AirSyncBase:NativeBodyType", "1");
|
||||||
|
}
|
||||||
|
Some(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MANAGED: &[&str] = &[
|
||||||
|
"FN",
|
||||||
|
"N",
|
||||||
|
"ORG",
|
||||||
|
"TITLE",
|
||||||
|
"BDAY",
|
||||||
|
"ANNIVERSARY",
|
||||||
|
"X-ANNIVERSARY",
|
||||||
|
"URL",
|
||||||
|
"NOTE",
|
||||||
|
"CATEGORIES",
|
||||||
|
"NICKNAME",
|
||||||
|
"X-SPOUSE",
|
||||||
|
"X-ASSISTANT",
|
||||||
|
"X-MS-ASSISTANT",
|
||||||
|
"X-MANAGER",
|
||||||
|
"X-OFFICE-LOCATION",
|
||||||
|
"X-PHONETIC-ORG",
|
||||||
|
"X-PHONETIC-FIRST-NAME",
|
||||||
|
"X-PHONETIC-LAST-NAME",
|
||||||
|
"X-CUSTOMER-ID",
|
||||||
|
"X-GOVERNMENT-ID",
|
||||||
|
"X-ACCOUNT-NAME",
|
||||||
|
"X-EAS-FILEAS",
|
||||||
|
"REV",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn from_eas(app: &Element, existing: Option<&str>, dtz: Tz) -> Option<String> {
|
||||||
|
let mut card = existing
|
||||||
|
.and_then(|e| ical::parse(e).into_iter().find(|c| c.name == "VCARD"))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let mut c = Component::new("VCARD");
|
||||||
|
c.add(Property::new("VERSION", "3.0"));
|
||||||
|
c.add(Property::new("PRODID", "-//ActivePush//ActiveSync//EN"));
|
||||||
|
c.add(Property::text("UID", &uuid::Uuid::new_v4().to_string()));
|
||||||
|
c
|
||||||
|
});
|
||||||
|
let v4 = card
|
||||||
|
.text("VERSION")
|
||||||
|
.map(|v| v.starts_with('4'))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let g = |n: &str| app.child_text(n).filter(|s| !s.trim().is_empty());
|
||||||
|
|
||||||
|
// keep overflow EMAIL/TEL/ADR/IMPP that EAS cannot represent
|
||||||
|
let old_emails: Vec<Property> = card.props_named("EMAIL").skip(3).cloned().collect();
|
||||||
|
let old_tels: Vec<Property> = card.props_named("TEL").cloned().collect();
|
||||||
|
let tel_refs: Vec<&Property> = old_tels.iter().collect();
|
||||||
|
let (_, tel_left) = assign_tels(&tel_refs);
|
||||||
|
let keep_tels: Vec<Property> = tel_left.iter().map(|i| old_tels[*i].clone()).collect();
|
||||||
|
let old_adrs: Vec<Property> = card.props_named("ADR").cloned().collect();
|
||||||
|
let adr_refs: Vec<&Property> = old_adrs.iter().collect();
|
||||||
|
let (_, adr_left) = assign_adrs(&adr_refs);
|
||||||
|
let keep_adrs: Vec<Property> = adr_left.iter().map(|i| old_adrs[*i].clone()).collect();
|
||||||
|
let old_impp: Vec<Property> = card.props_named("IMPP").skip(3).cloned().collect();
|
||||||
|
let old_photo = card.prop("PHOTO").cloned();
|
||||||
|
|
||||||
|
card.props.retain(|p| {
|
||||||
|
!MANAGED.iter().any(|m| p.name.eq_ignore_ascii_case(m))
|
||||||
|
&& !["EMAIL", "TEL", "ADR", "IMPP", "PHOTO"].contains(&p.name.as_str())
|
||||||
|
});
|
||||||
|
|
||||||
|
let (first, last, middle, title, suffix) = (
|
||||||
|
g("Contacts:FirstName").unwrap_or_default(),
|
||||||
|
g("Contacts:LastName").unwrap_or_default(),
|
||||||
|
g("Contacts:MiddleName").unwrap_or_default(),
|
||||||
|
g("Contacts:Title").unwrap_or_default(),
|
||||||
|
g("Contacts:Suffix").unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let company = g("Contacts:CompanyName").unwrap_or_default();
|
||||||
|
let fn_ = [
|
||||||
|
title.as_str(),
|
||||||
|
first.as_str(),
|
||||||
|
middle.as_str(),
|
||||||
|
last.as_str(),
|
||||||
|
suffix.as_str(),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
let fn_ = if fn_.is_empty() {
|
||||||
|
g("Contacts:FileAs")
|
||||||
|
.or_else(|| (!company.is_empty()).then(|| company.clone()))
|
||||||
|
.or_else(|| g("Contacts:Email1Address"))
|
||||||
|
.unwrap_or_else(|| "?".into())
|
||||||
|
} else {
|
||||||
|
fn_
|
||||||
|
};
|
||||||
|
card.add(Property::text("FN", &fn_));
|
||||||
|
card.add(Property::new(
|
||||||
|
"N",
|
||||||
|
[&last, &first, &middle, &title, &suffix]
|
||||||
|
.iter()
|
||||||
|
.map(|s| ical::escape_text(s))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";"),
|
||||||
|
));
|
||||||
|
if let Some(fa) = g("Contacts:FileAs") {
|
||||||
|
if fa != fn_ {
|
||||||
|
card.add(Property::text("X-EAS-FILEAS", &fa));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dept = g("Contacts:Department").unwrap_or_default();
|
||||||
|
if !company.is_empty() || !dept.is_empty() {
|
||||||
|
card.add(Property::new(
|
||||||
|
"ORG",
|
||||||
|
format!(
|
||||||
|
"{};{}",
|
||||||
|
ical::escape_text(&company),
|
||||||
|
ical::escape_text(&dept)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(t) = g("Contacts:JobTitle") {
|
||||||
|
card.add(Property::text("TITLE", &t));
|
||||||
|
}
|
||||||
|
for i in 1..=3 {
|
||||||
|
if let Some(e) = g(&format!("Contacts:Email{i}Address")) {
|
||||||
|
// devices send `"Name" <addr>`
|
||||||
|
let addr = e
|
||||||
|
.rsplit_once('<')
|
||||||
|
.map(|(_, a)| a.trim_end_matches('>').to_string())
|
||||||
|
.unwrap_or(e)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let typ = match i {
|
||||||
|
1 => "INTERNET,WORK",
|
||||||
|
2 => "INTERNET,HOME",
|
||||||
|
_ => "INTERNET,OTHER",
|
||||||
|
};
|
||||||
|
card.add(Property::text("EMAIL", &addr).with_param("TYPE", typ));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
card.props.extend(old_emails);
|
||||||
|
for (slot, _, _) in PHONE_SLOTS {
|
||||||
|
if let Some(n) = g(slot) {
|
||||||
|
card.add(Property::text("TEL", &n).with_param("TYPE", tel_types(slot)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
card.props.extend(keep_tels);
|
||||||
|
for (slot, typ) in ADR_SLOTS {
|
||||||
|
let f = |s: &str| g(&format!("Contacts:{slot}{s}")).unwrap_or_default();
|
||||||
|
let (street, city, state, zip, country) = (
|
||||||
|
f("Street"),
|
||||||
|
f("City"),
|
||||||
|
f("State"),
|
||||||
|
f("PostalCode"),
|
||||||
|
f("Country"),
|
||||||
|
);
|
||||||
|
if [&street, &city, &state, &zip, &country]
|
||||||
|
.iter()
|
||||||
|
.any(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
let v = ["", "", &street, &city, &state, &zip, &country]
|
||||||
|
.iter()
|
||||||
|
.map(|s| ical::escape_text(s))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";");
|
||||||
|
card.add(Property::new("ADR", v).with_param("TYPE", &typ.to_ascii_uppercase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
card.props.extend(keep_adrs);
|
||||||
|
if let Some(b) = g("Contacts:Birthday").and_then(|s| eas_to_date(&s, &dtz)) {
|
||||||
|
card.add(Property::new(
|
||||||
|
"BDAY",
|
||||||
|
b.format(if v4 { "%Y%m%d" } else { "%Y-%m-%d" }).to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(b) = g("Contacts:Anniversary").and_then(|s| eas_to_date(&s, &dtz)) {
|
||||||
|
let name = if v4 { "ANNIVERSARY" } else { "X-ANNIVERSARY" };
|
||||||
|
card.add(Property::new(
|
||||||
|
name,
|
||||||
|
b.format(if v4 { "%Y%m%d" } else { "%Y-%m-%d" }).to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(u) = g("Contacts:WebPage") {
|
||||||
|
card.add(Property::new("URL", u));
|
||||||
|
}
|
||||||
|
if let Some(n) = incoming_body(app, "Contacts").filter(|s| !s.trim().is_empty()) {
|
||||||
|
card.add(Property::text("NOTE", &n));
|
||||||
|
}
|
||||||
|
if let Some(c) = categories(app, "Contacts").filter(|c| !c.is_empty()) {
|
||||||
|
card.add(Property::new(
|
||||||
|
"CATEGORIES",
|
||||||
|
c.iter()
|
||||||
|
.map(|x| ical::escape_text(x))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (tag, prop) in [
|
||||||
|
("Contacts:AssistantName", "X-ASSISTANT"),
|
||||||
|
("Contacts:Spouse", "X-SPOUSE"),
|
||||||
|
("Contacts:OfficeLocation", "X-OFFICE-LOCATION"),
|
||||||
|
("Contacts:YomiCompanyName", "X-PHONETIC-ORG"),
|
||||||
|
("Contacts:YomiFirstName", "X-PHONETIC-FIRST-NAME"),
|
||||||
|
("Contacts:YomiLastName", "X-PHONETIC-LAST-NAME"),
|
||||||
|
("Contacts2:CustomerId", "X-CUSTOMER-ID"),
|
||||||
|
("Contacts2:GovernmentId", "X-GOVERNMENT-ID"),
|
||||||
|
("Contacts2:ManagerName", "X-MANAGER"),
|
||||||
|
("Contacts2:AccountName", "X-ACCOUNT-NAME"),
|
||||||
|
("Contacts2:NickName", "NICKNAME"),
|
||||||
|
] {
|
||||||
|
if let Some(v) = g(tag) {
|
||||||
|
card.add(Property::text(prop, &v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for t in [
|
||||||
|
"Contacts2:IMAddress",
|
||||||
|
"Contacts2:IMAddress2",
|
||||||
|
"Contacts2:IMAddress3",
|
||||||
|
] {
|
||||||
|
if let Some(v) = g(t) {
|
||||||
|
card.add(Property::text("IMPP", &v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
card.props.extend(old_impp);
|
||||||
|
match app.child("Contacts:Picture") {
|
||||||
|
Some(p) => {
|
||||||
|
let data = p.text_value();
|
||||||
|
if !data.is_empty()
|
||||||
|
&& base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(data.trim())
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
if v4 {
|
||||||
|
card.add(Property::new(
|
||||||
|
"PHOTO",
|
||||||
|
format!("data:image/jpeg;base64,{}", data.trim()),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
card.add(
|
||||||
|
Property::new("PHOTO", data.trim().to_string())
|
||||||
|
.with_param("ENCODING", "b")
|
||||||
|
.with_param("TYPE", "JPEG"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if let Some(p) = old_photo {
|
||||||
|
card.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
card.add(Property::new(
|
||||||
|
"REV",
|
||||||
|
Utc::now().format("%Y%m%dT%H%M%SZ").to_string(),
|
||||||
|
));
|
||||||
|
// VERSION must come first
|
||||||
|
if let Some(pos) = card.props.iter().position(|p| p.name == "VERSION") {
|
||||||
|
let v = card.props.remove(pos);
|
||||||
|
card.props.insert(0, v);
|
||||||
|
}
|
||||||
|
Some(card.serialize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short summary for GAL / ResolveRecipients.
|
||||||
|
pub struct CardSummary {
|
||||||
|
pub display_name: String,
|
||||||
|
pub first: String,
|
||||||
|
pub last: String,
|
||||||
|
pub emails: Vec<String>,
|
||||||
|
pub phone: String,
|
||||||
|
pub mobile: String,
|
||||||
|
pub company: String,
|
||||||
|
pub title: String,
|
||||||
|
pub office: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summary(vcf: &str) -> Option<CardSummary> {
|
||||||
|
let cards = ical::parse(vcf);
|
||||||
|
let c = cards.iter().find(|c| c.name == "VCARD")?;
|
||||||
|
let n = c
|
||||||
|
.prop("N")
|
||||||
|
.map(|n| ical::split_structured(&n.value))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let tels: Vec<&Property> = c.props_named("TEL").collect();
|
||||||
|
let (slots, _) = assign_tels(&tels);
|
||||||
|
let slot = |s: &str| {
|
||||||
|
slots
|
||||||
|
.iter()
|
||||||
|
.find(|(x, _)| *x == s)
|
||||||
|
.map(|(_, i)| tels[*i].text_value())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
Some(CardSummary {
|
||||||
|
display_name: c.text("FN").unwrap_or_default(),
|
||||||
|
first: n.get(1).cloned().unwrap_or_default(),
|
||||||
|
last: n.first().cloned().unwrap_or_default(),
|
||||||
|
emails: c.props_named("EMAIL").map(|e| e.text_value()).collect(),
|
||||||
|
phone: slot("Contacts:BusinessPhoneNumber"),
|
||||||
|
mobile: slot("Contacts:MobilePhoneNumber"),
|
||||||
|
company: c
|
||||||
|
.prop("ORG")
|
||||||
|
.map(|o| {
|
||||||
|
ical::split_structured(&o.value)
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
title: c.text("TITLE").unwrap_or_default(),
|
||||||
|
office: c.text("X-OFFICE-LOCATION").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_lossless() {
|
||||||
|
let v = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Jan Kowalski\r\nN:Kowalski;Jan;;;\r\nEMAIL;TYPE=WORK:a@x.pl\r\nEMAIL:b@x.pl\r\nEMAIL:c@x.pl\r\nEMAIL:d@x.pl\r\nTEL;TYPE=CELL:+48600\r\nTEL;TYPE=WORK:+4822\r\nTEL;TYPE=X-WEIRD:999\r\nADR;TYPE=HOME:;;Polna 1;Warszawa;;00-001;PL\r\nBDAY:1985-05-12\r\nX-SOCIAL:keep\r\nEND:VCARD\r\n";
|
||||||
|
let app = to_eas(
|
||||||
|
v,
|
||||||
|
&Options::default(),
|
||||||
|
141,
|
||||||
|
chrono_tz::Europe::Warsaw,
|
||||||
|
100000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
app.child_text("Contacts:MobilePhoneNumber").unwrap(),
|
||||||
|
"+48600"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.child_text("Contacts:BusinessPhoneNumber").unwrap(),
|
||||||
|
"+4822"
|
||||||
|
);
|
||||||
|
assert_eq!(app.child_text("Contacts:HomeCity").unwrap(), "Warszawa");
|
||||||
|
assert_eq!(
|
||||||
|
app.child_text("Contacts:Birthday").unwrap(),
|
||||||
|
"1985-05-11T22:00:00.000Z"
|
||||||
|
);
|
||||||
|
assert_eq!(app.child_text("Contacts:Email3Address").unwrap(), "c@x.pl");
|
||||||
|
let back = from_eas(&app, Some(v), chrono_tz::Europe::Warsaw).unwrap();
|
||||||
|
assert!(back.contains("d@x.pl"), "4th email kept");
|
||||||
|
assert!(back.contains("X-WEIRD"), "unmapped phone kept");
|
||||||
|
assert!(back.contains("X-SOCIAL:keep"));
|
||||||
|
assert!(back.contains("BDAY:1985-05-12"));
|
||||||
|
assert!(back.contains("UID:c1"));
|
||||||
|
assert!(back.starts_with("BEGIN:VCARD\r\nVERSION:3.0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
//! RFC 5322 message -> EAS Email ApplicationData.
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use mail_parser::{MessageParser, MimeHeaders, PartType};
|
||||||
|
|
||||||
|
use super::{body_element, legacy_trunc_size, BodyPref, Options, BODY_HTML, BODY_MIME, BODY_TEXT};
|
||||||
|
use crate::backend::ical;
|
||||||
|
use crate::backend::imap::FetchedMessage;
|
||||||
|
use crate::backend::tz;
|
||||||
|
use crate::util::{html_to_text, text_to_html, truncate_bytes};
|
||||||
|
use crate::wbxml::Element;
|
||||||
|
|
||||||
|
pub struct MailContext<'a> {
|
||||||
|
pub collection: &'a str,
|
||||||
|
pub ver: u32,
|
||||||
|
pub opts: &'a Options,
|
||||||
|
pub default_tz: chrono_tz::Tz,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_addr(name: Option<&str>, addr: Option<&str>) -> String {
|
||||||
|
match (name.map(str::trim).filter(|n| !n.is_empty()), addr) {
|
||||||
|
(Some(n), Some(a)) => format!("\"{}\" <{}>", n.replace('"', "'"), a),
|
||||||
|
(None, Some(a)) => a.to_string(),
|
||||||
|
(Some(n), None) => n.to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addr_list(a: Option<&mail_parser::Address>) -> Option<String> {
|
||||||
|
let a = a?;
|
||||||
|
let v: Vec<String> = a
|
||||||
|
.iter()
|
||||||
|
.map(|x| fmt_addr(x.name(), x.address()))
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
(!v.is_empty()).then(|| v.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_names(a: Option<&mail_parser::Address>) -> Option<String> {
|
||||||
|
let a = a?;
|
||||||
|
let v: Vec<String> = a
|
||||||
|
.iter()
|
||||||
|
.map(|x| x.name().or(x.address()).unwrap_or("").to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
(!v.is_empty()).then(|| v.join("; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_reference(collection: &str, uid: u32, idx: usize) -> String {
|
||||||
|
format!("{collection}:{uid}:{idx}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_file_reference(r: &str) -> Option<(String, u32, usize)> {
|
||||||
|
let mut it = r.rsplitn(3, ':');
|
||||||
|
let idx = it.next()?.parse().ok()?;
|
||||||
|
let uid = it.next()?.parse().ok()?;
|
||||||
|
let col = it.next()?.to_string();
|
||||||
|
Some((col, uid, idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outlook compatible GlobalObjId wrapping an iCalendar UID.
|
||||||
|
pub fn global_obj_id(uid: &str) -> String {
|
||||||
|
let mut v =
|
||||||
|
hex::decode("040000008200E00074C5B7101A82E0080000000000000000000000000000000000000000")
|
||||||
|
.unwrap();
|
||||||
|
if uid.len() <= 64 {
|
||||||
|
v.extend_from_slice(&((13 + uid.len()) as u32).to_le_bytes());
|
||||||
|
v.extend_from_slice(b"vCal-Uid");
|
||||||
|
v.extend_from_slice(&1u32.to_le_bytes());
|
||||||
|
v.extend_from_slice(uid.as_bytes());
|
||||||
|
v.push(0);
|
||||||
|
} else if let Ok(b) = hex::decode(uid) {
|
||||||
|
v = b;
|
||||||
|
}
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uid_from_global_obj_id(b64: &str) -> Option<String> {
|
||||||
|
let b = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(b64.trim())
|
||||||
|
.ok()?;
|
||||||
|
let pos = b
|
||||||
|
.windows(8)
|
||||||
|
.position(|w| w.eq_ignore_ascii_case(b"vCal-Uid"))?;
|
||||||
|
if pos < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let len = u32::from_le_bytes([b[pos - 4], b[pos - 3], b[pos - 2], b[pos - 1]]) as usize;
|
||||||
|
let start = pos + 12;
|
||||||
|
let end = (start + len.saturating_sub(13)).min(b.len());
|
||||||
|
Some(String::from_utf8_lossy(&b[start..end]).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conversation_id(subject: &str) -> Vec<u8> {
|
||||||
|
let s = subject.trim();
|
||||||
|
let mut t = s;
|
||||||
|
loop {
|
||||||
|
let l = t.to_ascii_lowercase();
|
||||||
|
let stripped = [
|
||||||
|
"re:", "fw:", "fwd:", "aw:", "odp:", "pd:", "wg:", "sv:", "tr:",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.find(|p| l.starts_with(*p))
|
||||||
|
.map(|p| t[p.len()..].trim_start());
|
||||||
|
match stripped {
|
||||||
|
Some(n) => t = n,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
Md5::digest(t.to_lowercase().as_bytes()).to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Converted {
|
||||||
|
pub app: Element,
|
||||||
|
pub size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a fetched message to ApplicationData.
|
||||||
|
pub fn to_eas(m: &FetchedMessage, ctx: &MailContext) -> Converted {
|
||||||
|
let parser = MessageParser::default();
|
||||||
|
let msg = parser.parse(&m.body);
|
||||||
|
let mut app = Element::new("AirSync:ApplicationData");
|
||||||
|
let ver = ctx.ver;
|
||||||
|
let Some(msg) = msg else {
|
||||||
|
app.push_text("Email:Subject", "(unparseable message)");
|
||||||
|
app.push_text("Email:Read", "0");
|
||||||
|
app.push_text("Email:MessageClass", "IPM.Note");
|
||||||
|
return Converted {
|
||||||
|
app,
|
||||||
|
size: m.body.len(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let subject = msg.subject().unwrap_or("").to_string();
|
||||||
|
app.push_opt("Email:To", addr_list(msg.to()));
|
||||||
|
app.push_opt("Email:Cc", addr_list(msg.cc()));
|
||||||
|
app.push_opt("Email:From", addr_list(msg.from()));
|
||||||
|
app.push_text("Email:Subject", subject.clone());
|
||||||
|
app.push_opt("Email:ReplyTo", addr_list(msg.reply_to()));
|
||||||
|
let date = m
|
||||||
|
.internal_date
|
||||||
|
.or_else(|| {
|
||||||
|
msg.date()
|
||||||
|
.and_then(|d| chrono::DateTime::from_timestamp(d.to_timestamp(), 0))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(chrono::Utc::now);
|
||||||
|
app.push_text("Email:DateReceived", tz::fmt_iso(&date));
|
||||||
|
app.push_opt("Email:DisplayTo", display_names(msg.to()));
|
||||||
|
app.push_text("Email:ThreadTopic", subject.clone());
|
||||||
|
let importance = {
|
||||||
|
let xp = msg
|
||||||
|
.header_raw("X-Priority")
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let imp = msg
|
||||||
|
.header_raw("Importance")
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if xp.starts_with('1') || xp.starts_with('2') || imp == "high" {
|
||||||
|
2
|
||||||
|
} else if xp.starts_with('4') || xp.starts_with('5') || imp == "low" {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
app.push_text("Email:Importance", importance.to_string());
|
||||||
|
let read = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Seen"));
|
||||||
|
app.push_text("Email:Read", if read { "1" } else { "0" });
|
||||||
|
|
||||||
|
// ---- attachments
|
||||||
|
let mut atts = Vec::new();
|
||||||
|
for (idx, part) in msg.attachments().enumerate() {
|
||||||
|
let name = part
|
||||||
|
.attachment_name()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let ct = part.content_type();
|
||||||
|
let ext = match ct.and_then(|c| c.subtype()).unwrap_or("bin") {
|
||||||
|
"calendar" => "ics",
|
||||||
|
"rfc822" => "eml",
|
||||||
|
s => s,
|
||||||
|
};
|
||||||
|
format!("attachment{}.{}", idx + 1, ext)
|
||||||
|
});
|
||||||
|
let size = part.contents().len();
|
||||||
|
let cid = part
|
||||||
|
.content_id()
|
||||||
|
.map(|c| c.trim_matches(['<', '>']).to_string());
|
||||||
|
let inline = part
|
||||||
|
.content_disposition()
|
||||||
|
.map(|d| d.ctype().eq_ignore_ascii_case("inline"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& cid.is_some();
|
||||||
|
atts.push((idx, name, size, cid, inline, part.is_message()));
|
||||||
|
}
|
||||||
|
if !atts.is_empty() {
|
||||||
|
if ver >= 120 {
|
||||||
|
let mut a = Element::new("AirSyncBase:Attachments");
|
||||||
|
for (idx, name, size, cid, inline, is_msg) in &atts {
|
||||||
|
let mut e = Element::new("AirSyncBase:Attachment")
|
||||||
|
.with_text("AirSyncBase:DisplayName", name.clone())
|
||||||
|
.with_text(
|
||||||
|
"AirSyncBase:FileReference",
|
||||||
|
file_reference(ctx.collection, m.uid, *idx),
|
||||||
|
)
|
||||||
|
.with_text("AirSyncBase:Method", if *is_msg { "5" } else { "1" })
|
||||||
|
.with_text("AirSyncBase:EstimatedDataSize", size.to_string());
|
||||||
|
if let Some(c) = cid {
|
||||||
|
e.push_text("AirSyncBase:ContentId", c.clone());
|
||||||
|
}
|
||||||
|
if *inline {
|
||||||
|
e.push_text("AirSyncBase:IsInline", "1");
|
||||||
|
}
|
||||||
|
a.push(e);
|
||||||
|
}
|
||||||
|
app.push(a);
|
||||||
|
} else {
|
||||||
|
let mut a = Element::new("Email:Attachments");
|
||||||
|
for (idx, name, size, _, _, _) in &atts {
|
||||||
|
a.push(
|
||||||
|
Element::new("Email:Attachment")
|
||||||
|
.with_text("Email:AttMethod", "1")
|
||||||
|
.with_text("Email:AttSize", size.to_string())
|
||||||
|
.with_text("Email:DisplayName", name.clone())
|
||||||
|
.with_text("Email:AttName", file_reference(ctx.collection, m.uid, *idx)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
app.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- body
|
||||||
|
let html = msg
|
||||||
|
.body_html(0)
|
||||||
|
.map(|s| s.into_owned())
|
||||||
|
.filter(|_| msg.html_body_count() > 0 && has_real_html(&msg));
|
||||||
|
let text = msg.body_text(0).map(|s| s.into_owned());
|
||||||
|
let plain = text
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| html.as_deref().map(html_to_text).unwrap_or_default());
|
||||||
|
let ctype = msg
|
||||||
|
.content_type()
|
||||||
|
.map(|c| format!("{}/{}", c.ctype(), c.subtype().unwrap_or("")).to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let is_smime = ctype.contains("signed") || ctype.contains("pkcs7");
|
||||||
|
let native = if html.is_some() { BODY_HTML } else { BODY_TEXT };
|
||||||
|
let opts = ctx.opts;
|
||||||
|
if ver >= 120 {
|
||||||
|
let want_mime = opts.wants(BODY_MIME).is_some()
|
||||||
|
&& (opts.mime_support == 2 || (opts.mime_support == 1 && is_smime));
|
||||||
|
let (typ, data): (u8, String) = if want_mime {
|
||||||
|
(BODY_MIME, String::from_utf8_lossy(&m.body).into_owned())
|
||||||
|
} else if opts.wants(BODY_HTML).is_some()
|
||||||
|
&& (html.is_some() || opts.wants(BODY_TEXT).is_none())
|
||||||
|
{
|
||||||
|
(
|
||||||
|
BODY_HTML,
|
||||||
|
html.clone().unwrap_or_else(|| text_to_html(&plain)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(BODY_TEXT, plain.replace("\r\n", "\n").replace('\n', "\r\n"))
|
||||||
|
};
|
||||||
|
let pref = opts.wants(typ).cloned().unwrap_or(BodyPref {
|
||||||
|
typ,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
app.push(body_element(typ, &data, &pref, Some(&plain)));
|
||||||
|
} else {
|
||||||
|
// AS 2.5
|
||||||
|
if opts.mime_support == 2 || (opts.mime_support == 1 && is_smime) {
|
||||||
|
let raw = String::from_utf8_lossy(&m.body).into_owned();
|
||||||
|
let limit = opts.mime_trunc.and_then(legacy_trunc_size);
|
||||||
|
let t = limit.map(|l| l < raw.len()).unwrap_or(false);
|
||||||
|
app.push_text("Email:MIMETruncated", if t { "1" } else { "0" });
|
||||||
|
app.push_text("Email:MIMESize", raw.len().to_string());
|
||||||
|
app.push_text(
|
||||||
|
"Email:MIMEData",
|
||||||
|
truncate_bytes(&raw, limit.unwrap_or(usize::MAX)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let limit = opts.legacy_trunc.and_then(legacy_trunc_size);
|
||||||
|
let t = limit.map(|l| l < plain.len()).unwrap_or(false);
|
||||||
|
app.push_text("Email:BodyTruncated", if t { "1" } else { "0" });
|
||||||
|
app.push_text(
|
||||||
|
"Email:Body",
|
||||||
|
truncate_bytes(&plain, limit.unwrap_or(usize::MAX)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- class / meeting
|
||||||
|
let mut class = if is_smime {
|
||||||
|
if ctype.contains("signed") {
|
||||||
|
"IPM.Note.SMIME.MultipartSigned"
|
||||||
|
} else {
|
||||||
|
"IPM.Note.SMIME"
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
"IPM.Note".to_string()
|
||||||
|
};
|
||||||
|
let mut meeting: Option<Element> = None;
|
||||||
|
if let Some(ics) = find_calendar_part(&msg) {
|
||||||
|
if let Some((cls, mr)) = meeting_request(&ics, ctx.default_tz) {
|
||||||
|
class = cls;
|
||||||
|
meeting = mr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.push_text("Email:MessageClass", class);
|
||||||
|
if let Some(mr) = meeting {
|
||||||
|
app.push(mr);
|
||||||
|
}
|
||||||
|
app.push_text("Email:InternetCPID", "65001");
|
||||||
|
if ver >= 120 {
|
||||||
|
let flagged = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Flagged"));
|
||||||
|
if flagged {
|
||||||
|
app.push(
|
||||||
|
Element::new("Email:Flag")
|
||||||
|
.with_text("Email:FlagStatus", "2")
|
||||||
|
.with_text("Email:FlagType", "Flag for follow up"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
app.push(Element::new("Email:Flag"));
|
||||||
|
}
|
||||||
|
app.push_text("Email:ContentClass", "urn:content-classes:message");
|
||||||
|
app.push_text("AirSyncBase:NativeBodyType", native.to_string());
|
||||||
|
}
|
||||||
|
if ver >= 140 {
|
||||||
|
app.push(Element::opaque(
|
||||||
|
"Email2:ConversationId",
|
||||||
|
conversation_id(&subject),
|
||||||
|
));
|
||||||
|
let mut idx = Vec::with_capacity(5);
|
||||||
|
idx.push(0x01);
|
||||||
|
idx.extend_from_slice(&(date.timestamp() as u32).to_be_bytes());
|
||||||
|
app.push(Element::opaque("Email2:ConversationIndex", idx));
|
||||||
|
let answered = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Answered"));
|
||||||
|
let forwarded = m
|
||||||
|
.flags
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.eq_ignore_ascii_case("$Forwarded") || f.eq_ignore_ascii_case("Forwarded"));
|
||||||
|
if answered || forwarded {
|
||||||
|
app.push_text("Email2:LastVerbExecuted", if forwarded { "3" } else { "1" });
|
||||||
|
app.push_text("Email2:LastVerbExecutionTime", tz::fmt_iso(&date));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Converted {
|
||||||
|
app,
|
||||||
|
size: m.body.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes-only payload (flags) for Sync Change commands.
|
||||||
|
pub fn flags_change(flags: &[String], ver: u32) -> Element {
|
||||||
|
let mut app = Element::new("AirSync:ApplicationData");
|
||||||
|
let read = flags.iter().any(|f| f.eq_ignore_ascii_case("\\Seen"));
|
||||||
|
app.push_text("Email:Read", if read { "1" } else { "0" });
|
||||||
|
if ver >= 120 {
|
||||||
|
if flags.iter().any(|f| f.eq_ignore_ascii_case("\\Flagged")) {
|
||||||
|
app.push(
|
||||||
|
Element::new("Email:Flag")
|
||||||
|
.with_text("Email:FlagStatus", "2")
|
||||||
|
.with_text("Email:FlagType", "Flag for follow up"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
app.push(Element::new("Email:Flag"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flags_from_fingerprint(fp: &str) -> Vec<String> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
for c in fp.chars() {
|
||||||
|
match c {
|
||||||
|
'S' => v.push("\\Seen".to_string()),
|
||||||
|
'F' => v.push("\\Flagged".to_string()),
|
||||||
|
'A' => v.push("\\Answered".to_string()),
|
||||||
|
'W' => v.push("$Forwarded".to_string()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_real_html(msg: &mail_parser::Message) -> bool {
|
||||||
|
msg.html_bodies()
|
||||||
|
.any(|p| matches!(p.body, PartType::Html(_)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_calendar_part(msg: &mail_parser::Message) -> Option<String> {
|
||||||
|
for part in msg.parts.iter() {
|
||||||
|
let ct = part.content_type();
|
||||||
|
let is_cal = ct
|
||||||
|
.map(|c| {
|
||||||
|
c.ctype().eq_ignore_ascii_case("text")
|
||||||
|
&& c.subtype()
|
||||||
|
.map(|s| s.eq_ignore_ascii_case("calendar"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if is_cal {
|
||||||
|
if let Some(t) = part.text_contents() {
|
||||||
|
return Some(t.to_string());
|
||||||
|
}
|
||||||
|
return Some(String::from_utf8_lossy(part.contents()).into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build Email:MeetingRequest from an iMIP calendar part.
|
||||||
|
fn meeting_request(ics: &str, default_tz: chrono_tz::Tz) -> Option<(String, Option<Element>)> {
|
||||||
|
let cals = ical::parse(ics);
|
||||||
|
let cal = cals.first()?;
|
||||||
|
let method = cal.text("METHOD").unwrap_or_default().to_ascii_uppercase();
|
||||||
|
let ev = cal.child("VEVENT")?;
|
||||||
|
let class = match method.as_str() {
|
||||||
|
"REQUEST" => "IPM.Schedule.Meeting.Request",
|
||||||
|
"CANCEL" => "IPM.Schedule.Meeting.Canceled",
|
||||||
|
"REPLY" => {
|
||||||
|
let st = ev
|
||||||
|
.prop("ATTENDEE")
|
||||||
|
.and_then(|a| a.param("PARTSTAT"))
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_uppercase();
|
||||||
|
match st.as_str() {
|
||||||
|
"ACCEPTED" => "IPM.Schedule.Meeting.Resp.Pos",
|
||||||
|
"TENTATIVE" => "IPM.Schedule.Meeting.Resp.Tent",
|
||||||
|
"DECLINED" => "IPM.Schedule.Meeting.Resp.Neg",
|
||||||
|
_ => "IPM.Note",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
if !(method == "REQUEST" || method == "CANCEL") {
|
||||||
|
return Some((class.to_string(), None));
|
||||||
|
}
|
||||||
|
let start = ev
|
||||||
|
.prop("DTSTART")
|
||||||
|
.and_then(|p| tz::parse_ical_time(p, default_tz))?;
|
||||||
|
let end = ev
|
||||||
|
.prop("DTEND")
|
||||||
|
.and_then(|p| tz::parse_ical_time(p, default_tz))
|
||||||
|
.map(|t| t.utc)
|
||||||
|
.unwrap_or(start.utc + chrono::Duration::hours(1));
|
||||||
|
let mut mr = Element::new("Email:MeetingRequest");
|
||||||
|
mr.push_text("Email:AllDayEvent", if start.all_day { "1" } else { "0" });
|
||||||
|
mr.push_text("Email:StartTime", tz::fmt_iso(&start.utc));
|
||||||
|
let stamp = ev
|
||||||
|
.prop("DTSTAMP")
|
||||||
|
.and_then(|p| tz::parse_ical_time(p, default_tz))
|
||||||
|
.map(|t| t.utc)
|
||||||
|
.unwrap_or_else(chrono::Utc::now);
|
||||||
|
mr.push_text("Email:DtStamp", tz::fmt_iso(&stamp));
|
||||||
|
mr.push_text("Email:EndTime", tz::fmt_iso(&end));
|
||||||
|
mr.push_text(
|
||||||
|
"Email:InstanceType",
|
||||||
|
if ev.prop("RRULE").is_some() { "1" } else { "0" },
|
||||||
|
);
|
||||||
|
mr.push_opt("Email:Location", ev.text("LOCATION"));
|
||||||
|
if let Some(o) = ev.prop("ORGANIZER") {
|
||||||
|
let addr = o
|
||||||
|
.value
|
||||||
|
.trim_start_matches("mailto:")
|
||||||
|
.trim_start_matches("MAILTO:")
|
||||||
|
.to_string();
|
||||||
|
mr.push_text("Email:Organizer", fmt_addr(o.param("CN"), Some(&addr)));
|
||||||
|
}
|
||||||
|
if ev.child("VALARM").is_some() {
|
||||||
|
mr.push_text("Email:Reminder", "15");
|
||||||
|
}
|
||||||
|
mr.push_text("Email:ResponseRequested", "1");
|
||||||
|
let sens = match ev
|
||||||
|
.text("CLASS")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_uppercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"PRIVATE" => "2",
|
||||||
|
"CONFIDENTIAL" => "3",
|
||||||
|
_ => "0",
|
||||||
|
};
|
||||||
|
mr.push_text("Email:Sensitivity", sens);
|
||||||
|
mr.push_text("Email:BusyStatus", "2");
|
||||||
|
let tzone = start.tz.unwrap_or(default_tz);
|
||||||
|
mr.push_text(
|
||||||
|
"Email:TimeZone",
|
||||||
|
tz::blob_for(&tzone, chrono::Datelike::year(&start.utc)),
|
||||||
|
);
|
||||||
|
if let Some(uid) = ev.text("UID") {
|
||||||
|
mr.push_text("Email:GlobalObjId", global_obj_id(&uid));
|
||||||
|
}
|
||||||
|
mr.push_text("Email2:MeetingMessageType", "1");
|
||||||
|
Some((class.to_string(), Some(mr)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw bytes of one attachment by index (as numbered in `to_eas`).
|
||||||
|
pub fn attachment(raw: &[u8], idx: usize) -> Option<(String, String, Vec<u8>)> {
|
||||||
|
let msg = MessageParser::default().parse(raw)?;
|
||||||
|
let part = msg.attachments().nth(idx)?;
|
||||||
|
let ct = part
|
||||||
|
.content_type()
|
||||||
|
.map(|c| format!("{}/{}", c.ctype(), c.subtype().unwrap_or("octet-stream")))
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".into());
|
||||||
|
let name = part.attachment_name().unwrap_or("attachment").to_string();
|
||||||
|
let data = if part.is_message() {
|
||||||
|
// keep the embedded message raw
|
||||||
|
raw.get(part.raw_body_offset() as usize..part.raw_end_offset() as usize)
|
||||||
|
.map(|s| s.to_vec())
|
||||||
|
.unwrap_or_else(|| part.contents().to_vec())
|
||||||
|
} else {
|
||||||
|
part.contents().to_vec()
|
||||||
|
};
|
||||||
|
Some((name, ct, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calendar part of a message (for MeetingResponse).
|
||||||
|
pub fn calendar_of(raw: &[u8]) -> Option<String> {
|
||||||
|
let msg = MessageParser::default().parse(raw)?;
|
||||||
|
find_calendar_part(&msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn goid_roundtrip() {
|
||||||
|
let g = global_obj_id("abc-123@example.com");
|
||||||
|
assert_eq!(uid_from_global_obj_id(&g).unwrap(), "abc-123@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_simple() {
|
||||||
|
let raw = "From: Jan <jan@x.pl>\r\nTo: Anna <anna@x.pl>\r\nSubject: =?utf-8?q?Za=C5=BC=C3=B3=C5=82=C4=87?=\r\nDate: Thu, 05 Nov 2026 10:00:00 +0100\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nTreść wiadomości\r\n".as_bytes().to_vec();
|
||||||
|
let m = FetchedMessage {
|
||||||
|
uid: 7,
|
||||||
|
flags: vec!["\\Seen".into()],
|
||||||
|
internal_date: None,
|
||||||
|
body: raw,
|
||||||
|
size: 0,
|
||||||
|
};
|
||||||
|
let opts = Options {
|
||||||
|
prefs: vec![BodyPref {
|
||||||
|
typ: 1,
|
||||||
|
trunc: Some(5),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let c = to_eas(
|
||||||
|
&m,
|
||||||
|
&MailContext {
|
||||||
|
collection: "3",
|
||||||
|
ver: 141,
|
||||||
|
opts: &opts,
|
||||||
|
default_tz: chrono_tz::UTC,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(c.app.child_text("Email:Subject").unwrap(), "Zażółć");
|
||||||
|
assert_eq!(c.app.child_text("Email:Read").unwrap(), "1");
|
||||||
|
let body = c.app.child("AirSyncBase:Body").unwrap();
|
||||||
|
assert_eq!(body.child_text("AirSyncBase:Truncated").unwrap(), "1");
|
||||||
|
assert_eq!(body.child_text("AirSyncBase:Data").unwrap(), "Treś");
|
||||||
|
assert!(c.app.child_text("Email:From").unwrap().contains("jan@x.pl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fileref() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_file_reference("12:345:2"),
|
||||||
|
Some(("12".into(), 345, 2))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//! Conversions between backend formats (RFC 5322, iCalendar, vCard) and EAS ApplicationData.
|
||||||
|
|
||||||
|
pub mod calendar;
|
||||||
|
pub mod contact;
|
||||||
|
pub mod email;
|
||||||
|
pub mod task;
|
||||||
|
|
||||||
|
use crate::util::{html_to_text, text_to_html, truncate_bytes};
|
||||||
|
use crate::wbxml::Element;
|
||||||
|
|
||||||
|
pub const BODY_TEXT: u8 = 1;
|
||||||
|
pub const BODY_HTML: u8 = 2;
|
||||||
|
pub const BODY_RTF: u8 = 3;
|
||||||
|
pub const BODY_MIME: u8 = 4;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct BodyPref {
|
||||||
|
pub typ: u8,
|
||||||
|
pub trunc: Option<u32>,
|
||||||
|
pub all_or_none: bool,
|
||||||
|
pub preview: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options of a collection (or ItemOperations/Search fetch).
|
||||||
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Options {
|
||||||
|
pub prefs: Vec<BodyPref>,
|
||||||
|
pub mime_support: u8,
|
||||||
|
/// AS 2.5 Truncation code (0..9)
|
||||||
|
pub legacy_trunc: Option<u8>,
|
||||||
|
pub mime_trunc: Option<u8>,
|
||||||
|
pub filter_type: u8,
|
||||||
|
pub conflict: Option<u8>,
|
||||||
|
pub max_items: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Options {
|
||||||
|
pub fn parse(opts: &Element) -> Options {
|
||||||
|
let mut o = Options::default();
|
||||||
|
for bp in opts.children_named("AirSyncBase:BodyPreference") {
|
||||||
|
o.prefs.push(BodyPref {
|
||||||
|
typ: bp.child_u32("AirSyncBase:Type").unwrap_or(1) as u8,
|
||||||
|
trunc: bp.child_u32("AirSyncBase:TruncationSize"),
|
||||||
|
all_or_none: bp.child_u32("AirSyncBase:AllOrNone").unwrap_or(0) == 1,
|
||||||
|
preview: bp.child_u32("AirSyncBase:Preview"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
o.mime_support = opts.child_u32("AirSync:MIMESupport").unwrap_or(0) as u8;
|
||||||
|
o.legacy_trunc = opts.child_u32("AirSync:Truncation").map(|v| v as u8);
|
||||||
|
o.mime_trunc = opts.child_u32("AirSync:MIMETruncation").map(|v| v as u8);
|
||||||
|
o.filter_type = opts.child_u32("AirSync:FilterType").unwrap_or(0) as u8;
|
||||||
|
o.conflict = opts.child_u32("AirSync:Conflict").map(|v| v as u8);
|
||||||
|
o.max_items = opts.child_u32("AirSync:MaxItems");
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wants(&self, t: u8) -> Option<&BodyPref> {
|
||||||
|
self.prefs.iter().find(|p| p.typ == t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AS 2.5 truncation codes to byte sizes.
|
||||||
|
pub fn legacy_trunc_size(code: u8) -> Option<usize> {
|
||||||
|
match code {
|
||||||
|
0 => Some(0),
|
||||||
|
1 => Some(4096),
|
||||||
|
2 => Some(5120),
|
||||||
|
3 => Some(7168),
|
||||||
|
4 => Some(10240),
|
||||||
|
5 => Some(20480),
|
||||||
|
6 => Some(51200),
|
||||||
|
7 => Some(102400),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an AirSyncBase:Body element for PIM items (calendar/contacts/tasks), which store
|
||||||
|
/// plain text natively.
|
||||||
|
pub fn pim_body(text: &str, opts: &Options, ver: u32) -> Option<Element> {
|
||||||
|
if ver < 120 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let want_html = opts.wants(BODY_HTML).is_some() && opts.wants(BODY_TEXT).is_none();
|
||||||
|
let pref = opts
|
||||||
|
.wants(if want_html { BODY_HTML } else { BODY_TEXT })
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(BodyPref {
|
||||||
|
typ: BODY_TEXT,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let data = if pref.typ == BODY_HTML {
|
||||||
|
text_to_html(text)
|
||||||
|
} else {
|
||||||
|
text.replace("\r\n", "\n").replace('\n', "\r\n")
|
||||||
|
};
|
||||||
|
Some(body_element(pref.typ, &data, &pref, Some(text)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create AirSyncBase:Body with truncation / preview handling.
|
||||||
|
pub fn body_element(
|
||||||
|
typ: u8,
|
||||||
|
data: &str,
|
||||||
|
pref: &BodyPref,
|
||||||
|
plain_for_preview: Option<&str>,
|
||||||
|
) -> Element {
|
||||||
|
let full = data.len();
|
||||||
|
let mut body = Element::new("AirSyncBase:Body").with_text("AirSyncBase:Type", typ.to_string());
|
||||||
|
let truncated = pref.trunc.map(|t| (t as usize) < full).unwrap_or(false);
|
||||||
|
body.push_text("AirSyncBase:EstimatedDataSize", full.to_string());
|
||||||
|
if truncated {
|
||||||
|
body.push_text("AirSyncBase:Truncated", "1");
|
||||||
|
if !pref.all_or_none {
|
||||||
|
let t = pref.trunc.unwrap_or(0) as usize;
|
||||||
|
body.push_text("AirSyncBase:Data", truncate_bytes(data, t));
|
||||||
|
}
|
||||||
|
} else if full > 0 {
|
||||||
|
body.push_text("AirSyncBase:Data", data);
|
||||||
|
}
|
||||||
|
if let Some(p) = pref.preview.filter(|p| *p > 0) {
|
||||||
|
let src = plain_for_preview.map(|s| s.to_string()).unwrap_or_else(|| {
|
||||||
|
if typ == BODY_HTML {
|
||||||
|
html_to_text(data)
|
||||||
|
} else {
|
||||||
|
data.to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let flat: String = src.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
body.push_text(
|
||||||
|
"AirSyncBase:Preview",
|
||||||
|
truncate_bytes(&flat, p.min(255) as usize),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract plain text from a client supplied body (AirSyncBase:Body or legacy fields).
|
||||||
|
pub fn incoming_body(app: &Element, legacy_ns: &str) -> Option<String> {
|
||||||
|
if let Some(b) = app.child("AirSyncBase:Body") {
|
||||||
|
let typ = b.child_u32("AirSyncBase:Type").unwrap_or(1);
|
||||||
|
let data = b.child_text("AirSyncBase:Data")?;
|
||||||
|
return Some(if typ == 2_u32 {
|
||||||
|
html_to_text(&data)
|
||||||
|
} else {
|
||||||
|
data.replace("\r\n", "\n")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.child_text(&format!("{legacy_ns}:Body"))
|
||||||
|
.map(|s| s.replace("\r\n", "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn categories(app: &Element, ns: &str) -> Option<Vec<String>> {
|
||||||
|
app.child(&format!("{ns}:Categories")).map(|c| {
|
||||||
|
c.children_named(&format!("{ns}:Category"))
|
||||||
|
.map(|x| x.text_value())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn categories_element(ns: &str, cats: &[String]) -> Option<Element> {
|
||||||
|
if cats.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut c = Element::new(&format!("{ns}:Categories"));
|
||||||
|
for x in cats {
|
||||||
|
c.push_text(&format!("{ns}:Category"), x.clone());
|
||||||
|
}
|
||||||
|
Some(c)
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
//! iCalendar VTODO <-> EAS Tasks.
|
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
|
||||||
|
use super::calendar::{eas_to_rrule, masters_and_overrides, rrule_to_eas};
|
||||||
|
use super::{categories, categories_element, incoming_body, pim_body, Options};
|
||||||
|
use crate::backend::ical::{self, Component, Property};
|
||||||
|
use crate::backend::tz;
|
||||||
|
use crate::wbxml::Element;
|
||||||
|
|
||||||
|
/// "Local" EAS task dates are local wall-clock times written with a Z suffix.
|
||||||
|
fn local_as_utc(d: &DateTime<Utc>, tzone: &Tz) -> String {
|
||||||
|
tz::fmt_iso(&Utc.from_utc_datetime(&d.with_timezone(tzone).naive_local()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utc_from_local_field(s: &str, tzone: &Tz) -> Option<DateTime<Utc>> {
|
||||||
|
let fake = tz::parse_eas_date(s)?;
|
||||||
|
let n: NaiveDateTime = fake.naive_utc();
|
||||||
|
tzone
|
||||||
|
.from_local_datetime(&n)
|
||||||
|
.earliest()
|
||||||
|
.map(|x| x.with_timezone(&Utc))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_eas(ics: &str, opts: &Options, ver: u32, dtz: Tz) -> Option<Element> {
|
||||||
|
let cals = ical::parse(ics);
|
||||||
|
let cal = cals.iter().find(|c| c.name == "VCALENDAR")?;
|
||||||
|
let (master, _) = masters_and_overrides(cal, "VTODO");
|
||||||
|
let t = master?;
|
||||||
|
let mut app = Element::new("AirSync:ApplicationData");
|
||||||
|
let desc = t.text("DESCRIPTION").unwrap_or_default();
|
||||||
|
if let Some(b) = pim_body(&desc, opts, ver) {
|
||||||
|
app.push(b);
|
||||||
|
} else if ver < 120 && !desc.is_empty() {
|
||||||
|
app.push_text("Tasks:Body", desc.clone());
|
||||||
|
}
|
||||||
|
let cats: Vec<String> = t
|
||||||
|
.props_named("CATEGORIES")
|
||||||
|
.flat_map(|p| ical::split_list(&p.value))
|
||||||
|
.collect();
|
||||||
|
if let Some(c) = categories_element("Tasks", &cats) {
|
||||||
|
app.push(c);
|
||||||
|
}
|
||||||
|
let completed = t
|
||||||
|
.text("STATUS")
|
||||||
|
.map(|s| s.eq_ignore_ascii_case("COMPLETED"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| t.prop("COMPLETED").is_some();
|
||||||
|
app.push_text("Tasks:Complete", if completed { "1" } else { "0" });
|
||||||
|
if completed {
|
||||||
|
let c = t
|
||||||
|
.prop("COMPLETED")
|
||||||
|
.and_then(|p| tz::parse_ical_time(p, dtz))
|
||||||
|
.map(|x| x.utc)
|
||||||
|
.unwrap_or_else(Utc::now);
|
||||||
|
app.push_text("Tasks:DateCompleted", tz::fmt_iso(&c));
|
||||||
|
}
|
||||||
|
let due = t.prop("DUE").and_then(|p| tz::parse_ical_time(p, dtz));
|
||||||
|
if let Some(d) = &due {
|
||||||
|
app.push_text("Tasks:DueDate", local_as_utc(&d.utc, &d.tz.unwrap_or(dtz)));
|
||||||
|
app.push_text("Tasks:UtcDueDate", tz::fmt_iso(&d.utc));
|
||||||
|
}
|
||||||
|
let prio: u32 = t.text("PRIORITY").and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
|
let imp = match prio {
|
||||||
|
1..=4 => 2,
|
||||||
|
6..=9 => 0,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
app.push_text("Tasks:Importance", imp.to_string());
|
||||||
|
let start = t.prop("DTSTART").and_then(|p| tz::parse_ical_time(p, dtz));
|
||||||
|
if let (Some(rr), Some(s)) = (t.prop("RRULE"), &start) {
|
||||||
|
if let Some(mut rec) =
|
||||||
|
rrule_to_eas(&rr.value, &s.utc, &s.tz.unwrap_or(dtz), "Tasks", s.all_day)
|
||||||
|
{
|
||||||
|
rec.push_text("Tasks:Start", local_as_utc(&s.utc, &s.tz.unwrap_or(dtz)));
|
||||||
|
app.push(rec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// reminder
|
||||||
|
let mut reminder: Option<DateTime<Utc>> = None;
|
||||||
|
if let Some(a) = t.child("VALARM") {
|
||||||
|
if let Some(tr) = a.prop("TRIGGER") {
|
||||||
|
if tr
|
||||||
|
.param("VALUE")
|
||||||
|
.map(|v| v.eq_ignore_ascii_case("DATE-TIME"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| tr.value.ends_with('Z')
|
||||||
|
{
|
||||||
|
reminder = tz::parse_eas_date(&tr.value);
|
||||||
|
} else if let Some(m) = super::calendar::reminder_minutes(&t) {
|
||||||
|
let base = if tr
|
||||||
|
.param("RELATED")
|
||||||
|
.map(|r| r.eq_ignore_ascii_case("END"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
due.as_ref().map(|d| d.utc)
|
||||||
|
} else {
|
||||||
|
start
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.utc)
|
||||||
|
.or(due.as_ref().map(|d| d.utc))
|
||||||
|
};
|
||||||
|
reminder = base.map(|b| b - chrono::Duration::minutes(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.push_text(
|
||||||
|
"Tasks:ReminderSet",
|
||||||
|
if reminder.is_some() { "1" } else { "0" },
|
||||||
|
);
|
||||||
|
if let Some(r) = reminder {
|
||||||
|
app.push_text("Tasks:ReminderTime", tz::fmt_iso(&r));
|
||||||
|
}
|
||||||
|
let sens = match t
|
||||||
|
.text("CLASS")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_uppercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"PRIVATE" => "2",
|
||||||
|
"CONFIDENTIAL" => "3",
|
||||||
|
_ => "0",
|
||||||
|
};
|
||||||
|
app.push_text("Tasks:Sensitivity", sens);
|
||||||
|
if let Some(s) = &start {
|
||||||
|
app.push_text(
|
||||||
|
"Tasks:StartDate",
|
||||||
|
local_as_utc(&s.utc, &s.tz.unwrap_or(dtz)),
|
||||||
|
);
|
||||||
|
app.push_text("Tasks:UtcStartDate", tz::fmt_iso(&s.utc));
|
||||||
|
}
|
||||||
|
app.push_text("Tasks:Subject", t.text("SUMMARY").unwrap_or_default());
|
||||||
|
Some(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MANAGED: &[&str] = &[
|
||||||
|
"SUMMARY",
|
||||||
|
"DESCRIPTION",
|
||||||
|
"STATUS",
|
||||||
|
"COMPLETED",
|
||||||
|
"PERCENT-COMPLETE",
|
||||||
|
"DUE",
|
||||||
|
"DTSTART",
|
||||||
|
"PRIORITY",
|
||||||
|
"CLASS",
|
||||||
|
"CATEGORIES",
|
||||||
|
"RRULE",
|
||||||
|
"DTSTAMP",
|
||||||
|
"LAST-MODIFIED",
|
||||||
|
"UID",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn from_eas(app: &Element, existing: Option<&str>, dtz: Tz) -> Option<String> {
|
||||||
|
let mut cal = existing
|
||||||
|
.and_then(|e| ical::parse(e).into_iter().find(|c| c.name == "VCALENDAR"))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let mut c = Component::new("VCALENDAR");
|
||||||
|
c.add(Property::new("VERSION", "2.0"));
|
||||||
|
c.add(Property::new("PRODID", "-//ActivePush//ActiveSync//EN"));
|
||||||
|
c
|
||||||
|
});
|
||||||
|
let (old, _) = masters_and_overrides(&cal, "VTODO");
|
||||||
|
let mut t = old.clone().unwrap_or_else(|| Component::new("VTODO"));
|
||||||
|
let uid = old
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|o| o.text("UID"))
|
||||||
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||||
|
t.props
|
||||||
|
.retain(|p| !MANAGED.iter().any(|m| p.name.eq_ignore_ascii_case(m)));
|
||||||
|
t.props.insert(0, Property::text("UID", &uid));
|
||||||
|
t.add(tz::ical_utc("DTSTAMP", &Utc::now()));
|
||||||
|
t.add(tz::ical_utc("LAST-MODIFIED", &Utc::now()));
|
||||||
|
if let Some(s) = app.child_text("Tasks:Subject") {
|
||||||
|
t.add(Property::text("SUMMARY", &s));
|
||||||
|
}
|
||||||
|
if let Some(b) = incoming_body(app, "Tasks").filter(|b| !b.trim().is_empty()) {
|
||||||
|
t.add(Property::text("DESCRIPTION", &b));
|
||||||
|
} else if !app.has("AirSyncBase:Body") {
|
||||||
|
if let Some(d) = old.as_ref().and_then(|o| o.prop("DESCRIPTION")) {
|
||||||
|
t.add(d.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let complete = app.child_u32("Tasks:Complete").unwrap_or(0) == 1;
|
||||||
|
if complete {
|
||||||
|
t.add(Property::new("STATUS", "COMPLETED"));
|
||||||
|
t.add(Property::new("PERCENT-COMPLETE", "100"));
|
||||||
|
let c = app
|
||||||
|
.child_text("Tasks:DateCompleted")
|
||||||
|
.and_then(|s| tz::parse_eas_date(&s))
|
||||||
|
.unwrap_or_else(Utc::now);
|
||||||
|
t.add(tz::ical_utc("COMPLETED", &c));
|
||||||
|
} else {
|
||||||
|
t.add(Property::new("STATUS", "NEEDS-ACTION"));
|
||||||
|
}
|
||||||
|
let due = app
|
||||||
|
.child_text("Tasks:UtcDueDate")
|
||||||
|
.and_then(|s| tz::parse_eas_date(&s))
|
||||||
|
.or_else(|| {
|
||||||
|
app.child_text("Tasks:DueDate")
|
||||||
|
.and_then(|s| utc_from_local_field(&s, &dtz))
|
||||||
|
});
|
||||||
|
if let Some(d) = due {
|
||||||
|
t.add(tz::ical_utc("DUE", &d));
|
||||||
|
}
|
||||||
|
let start = app
|
||||||
|
.child_text("Tasks:UtcStartDate")
|
||||||
|
.and_then(|s| tz::parse_eas_date(&s))
|
||||||
|
.or_else(|| {
|
||||||
|
app.child_text("Tasks:StartDate")
|
||||||
|
.and_then(|s| utc_from_local_field(&s, &dtz))
|
||||||
|
});
|
||||||
|
if let Some(s) = start {
|
||||||
|
t.add(tz::ical_utc("DTSTART", &s));
|
||||||
|
}
|
||||||
|
let prio = match app.child_u32("Tasks:Importance").unwrap_or(1) {
|
||||||
|
2 => "1",
|
||||||
|
0 => "9",
|
||||||
|
_ => "5",
|
||||||
|
};
|
||||||
|
t.add(Property::new("PRIORITY", prio));
|
||||||
|
if let Some(s) = app.child_text("Tasks:Sensitivity") {
|
||||||
|
t.add(Property::new(
|
||||||
|
"CLASS",
|
||||||
|
match s.as_str() {
|
||||||
|
"2" => "PRIVATE",
|
||||||
|
"3" => "CONFIDENTIAL",
|
||||||
|
_ => "PUBLIC",
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(cats) = categories(app, "Tasks").filter(|c| !c.is_empty()) {
|
||||||
|
t.add(Property::new(
|
||||||
|
"CATEGORIES",
|
||||||
|
cats.iter()
|
||||||
|
.map(|c| ical::escape_text(c))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(rec) = app.child("Tasks:Recurrence") {
|
||||||
|
if let Some(rr) = eas_to_rrule(rec, "Tasks", false, &dtz) {
|
||||||
|
t.add(Property::new("RRULE", rr));
|
||||||
|
if start.is_none() {
|
||||||
|
if let Some(s) = rec
|
||||||
|
.child_text("Tasks:Start")
|
||||||
|
.and_then(|s| utc_from_local_field(&s, &dtz))
|
||||||
|
{
|
||||||
|
t.add(tz::ical_utc("DTSTART", &s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.children.retain(|c| c.name != "VALARM");
|
||||||
|
if app.child_u32("Tasks:ReminderSet").unwrap_or(0) == 1 {
|
||||||
|
if let Some(r) = app
|
||||||
|
.child_text("Tasks:ReminderTime")
|
||||||
|
.and_then(|s| tz::parse_eas_date(&s))
|
||||||
|
{
|
||||||
|
let mut a = Component::new("VALARM");
|
||||||
|
a.add(Property::new("ACTION", "DISPLAY"));
|
||||||
|
a.add(Property::text(
|
||||||
|
"DESCRIPTION",
|
||||||
|
&t.text("SUMMARY").unwrap_or_else(|| "Reminder".into()),
|
||||||
|
));
|
||||||
|
a.add(tz::ical_utc("TRIGGER", &r).with_param("VALUE", "DATE-TIME"));
|
||||||
|
t.children.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cal.children.retain(|c| c.name != "VTODO");
|
||||||
|
cal.children.push(t);
|
||||||
|
Some(cal.serialize())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip() {
|
||||||
|
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nSUMMARY:Kup mleko\r\nDUE:20261106T120000Z\r\nPRIORITY:1\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
|
||||||
|
let app = to_eas(ics, &Options::default(), 141, chrono_tz::Europe::Warsaw).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
app.child_text("Tasks:UtcDueDate").unwrap(),
|
||||||
|
"2026-11-06T12:00:00.000Z"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.child_text("Tasks:DueDate").unwrap(),
|
||||||
|
"2026-11-06T13:00:00.000Z"
|
||||||
|
);
|
||||||
|
assert_eq!(app.child_text("Tasks:Importance").unwrap(), "2");
|
||||||
|
let mut app2 = app.clone();
|
||||||
|
app2.children.retain(|c| !c.is("Tasks:Complete"));
|
||||||
|
app2.push_text("Tasks:Complete", "1");
|
||||||
|
let back = from_eas(&app2, Some(ics), chrono_tz::Europe::Warsaw).unwrap();
|
||||||
|
assert!(back.contains("STATUS:COMPLETED"));
|
||||||
|
assert!(back.contains("UID:t1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
//! FolderSync, FolderCreate, FolderUpdate, FolderDelete, GetHierarchy.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use super::state::{self, HierarchyState, SyncKey, HIERARCHY};
|
||||||
|
use super::{Ctx, Reply};
|
||||||
|
use crate::backend::BFolder;
|
||||||
|
use crate::db;
|
||||||
|
use crate::wbxml::Element;
|
||||||
|
|
||||||
|
pub async fn short_id(ctx: &Ctx, backend_id: &str) -> Result<String> {
|
||||||
|
Ok(db::folder_short_id(&ctx.app.db, ctx.user(), backend_id)
|
||||||
|
.await?
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a device-visible folder id to the backend folder.
|
||||||
|
pub async fn resolve(ctx: &mut Ctx, short: &str) -> Result<Option<BFolder>> {
|
||||||
|
let Some(bid) = db::folder_backend_id(&ctx.app.db, ctx.user(), short).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
ctx.sess.folder(&bid).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn current_hierarchy(ctx: &mut Ctx) -> Result<(HierarchyState, Vec<(String, BFolder)>)> {
|
||||||
|
let folders = ctx.sess.folders().await?;
|
||||||
|
let mut st = HierarchyState::default();
|
||||||
|
let mut list = Vec::new();
|
||||||
|
let mut ids: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
for f in &folders {
|
||||||
|
let sid = short_id(ctx, &f.id).await?;
|
||||||
|
ids.insert(f.id.clone(), sid);
|
||||||
|
}
|
||||||
|
for f in folders {
|
||||||
|
let sid = ids[&f.id].clone();
|
||||||
|
let parent = f
|
||||||
|
.parent
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| ids.get(p).cloned())
|
||||||
|
.unwrap_or_else(|| "0".to_string());
|
||||||
|
st.folders
|
||||||
|
.insert(sid.clone(), (parent, f.name.clone(), f.ftype));
|
||||||
|
list.push((sid, f));
|
||||||
|
}
|
||||||
|
Ok((st, list))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder_el(tag: &str, sid: &str, parent: &str, name: &str, ftype: u8) -> Element {
|
||||||
|
Element::new(tag)
|
||||||
|
.with_text("FolderHierarchy:ServerId", sid)
|
||||||
|
.with_text("FolderHierarchy:ParentId", parent)
|
||||||
|
.with_text("FolderHierarchy:DisplayName", name)
|
||||||
|
.with_text("FolderHierarchy:Type", ftype.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fs_status(root: &str, status: u32) -> Reply {
|
||||||
|
Reply::Wbxml(Element::new(root).with_text("FolderHierarchy:Status", status.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn folder_sync(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||||
|
let Some(req) = req else {
|
||||||
|
return Ok(fs_status("FolderHierarchy:FolderSync", 10));
|
||||||
|
};
|
||||||
|
let key_s = req
|
||||||
|
.child_text("FolderHierarchy:SyncKey")
|
||||||
|
.unwrap_or_default();
|
||||||
|
let Some(key) = SyncKey::parse(&key_s) else {
|
||||||
|
ctx.status = "invalid synckey".into();
|
||||||
|
return Ok(fs_status("FolderHierarchy:FolderSync", 9));
|
||||||
|
};
|
||||||
|
let old: HierarchyState = match &key {
|
||||||
|
SyncKey::Initial => {
|
||||||
|
// full resync: forget every collection state of this device
|
||||||
|
db::device_reset_states(&ctx.app.db, ctx.dev.id).await?;
|
||||||
|
HierarchyState::default()
|
||||||
|
}
|
||||||
|
k => match state::load(ctx, HIERARCHY, k).await? {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
ctx.status = "unknown synckey".into();
|
||||||
|
return Ok(fs_status("FolderHierarchy:FolderSync", 9));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let (cur, _) = current_hierarchy(ctx).await?;
|
||||||
|
let mut adds = Vec::new();
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
let mut deletes = Vec::new();
|
||||||
|
for (sid, v) in &cur.folders {
|
||||||
|
match old.folders.get(sid) {
|
||||||
|
None => adds.push((sid.clone(), v.clone())),
|
||||||
|
Some(o) if o != v => updates.push((sid.clone(), v.clone())),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sid in old.folders.keys() {
|
||||||
|
if !cur.folders.contains_key(sid) {
|
||||||
|
deletes.push(sid.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// collections the device still references but which never existed here (Z-Push leftovers)
|
||||||
|
let stale: Vec<String> = ctx
|
||||||
|
.app
|
||||||
|
.stale_take(ctx.dev.id)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| !cur.folders.contains_key(s) && !deletes.contains(s))
|
||||||
|
.collect();
|
||||||
|
if !stale.is_empty() {
|
||||||
|
tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, folders = %stale.join(","), "removing stale collections from device");
|
||||||
|
deletes.extend(stale);
|
||||||
|
}
|
||||||
|
// parents before children
|
||||||
|
adds.sort_by_key(|(sid, (p, _, _))| (depth(&cur, sid), p.clone()));
|
||||||
|
let changed = !(adds.is_empty() && updates.is_empty() && deletes.is_empty());
|
||||||
|
let new_key = match (&key, changed) {
|
||||||
|
(SyncKey::Key { .. }, false) => key_s.clone(),
|
||||||
|
(SyncKey::Key { uuid, counter }, true) => {
|
||||||
|
state::save(ctx, HIERARCHY, uuid, counter + 1, &cur).await?;
|
||||||
|
state::make_key(uuid, counter + 1)
|
||||||
|
}
|
||||||
|
(SyncKey::Initial, _) => {
|
||||||
|
let u = state::new_uuid();
|
||||||
|
state::save(ctx, HIERARCHY, &u, 1, &cur).await?;
|
||||||
|
state::make_key(&u, 1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for sid in &deletes {
|
||||||
|
db::state_delete_collection(&ctx.app.db, ctx.dev.id, sid).await?;
|
||||||
|
}
|
||||||
|
let mut changes = Element::new("FolderHierarchy:Changes").with_text(
|
||||||
|
"FolderHierarchy:Count",
|
||||||
|
(adds.len() + updates.len() + deletes.len()).to_string(),
|
||||||
|
);
|
||||||
|
for (sid, (p, n, t)) in &updates {
|
||||||
|
changes.push(folder_el("FolderHierarchy:Update", sid, p, n, *t));
|
||||||
|
}
|
||||||
|
for sid in &deletes {
|
||||||
|
changes.push(
|
||||||
|
Element::new("FolderHierarchy:Delete")
|
||||||
|
.with_text("FolderHierarchy:ServerId", sid.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (sid, (p, n, t)) in &adds {
|
||||||
|
changes.push(folder_el("FolderHierarchy:Add", sid, p, n, *t));
|
||||||
|
}
|
||||||
|
ctx.items_out = (adds.len() + updates.len() + deletes.len()) as u64;
|
||||||
|
Ok(Reply::Wbxml(
|
||||||
|
Element::new("FolderHierarchy:FolderSync")
|
||||||
|
.with_text("FolderHierarchy:Status", "1")
|
||||||
|
.with_text("FolderHierarchy:SyncKey", new_key)
|
||||||
|
.with(changes),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn depth(st: &HierarchyState, sid: &str) -> usize {
|
||||||
|
let mut d = 0;
|
||||||
|
let mut cur = sid.to_string();
|
||||||
|
while let Some((p, _, _)) = st.folders.get(&cur) {
|
||||||
|
if p == "0" || d > 32 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
d += 1;
|
||||||
|
cur = p.clone();
|
||||||
|
}
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn folder_change(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||||
|
let cmd = ctx.req.cmd.clone();
|
||||||
|
let root = format!("FolderHierarchy:{cmd}");
|
||||||
|
let Some(req) = req else {
|
||||||
|
return Ok(fs_status(&root, 10));
|
||||||
|
};
|
||||||
|
let key_s = req
|
||||||
|
.child_text("FolderHierarchy:SyncKey")
|
||||||
|
.unwrap_or_default();
|
||||||
|
let Some(key @ SyncKey::Key { .. }) = SyncKey::parse(&key_s) else {
|
||||||
|
return Ok(fs_status(&root, 9));
|
||||||
|
};
|
||||||
|
let SyncKey::Key { uuid, counter } = &key else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let Some(mut st): Option<HierarchyState> = state::load(ctx, HIERARCHY, &key).await? else {
|
||||||
|
return Ok(fs_status(&root, 9));
|
||||||
|
};
|
||||||
|
let name = req
|
||||||
|
.child_text("FolderHierarchy:DisplayName")
|
||||||
|
.unwrap_or_default();
|
||||||
|
let parent_sid = req
|
||||||
|
.child_text("FolderHierarchy:ParentId")
|
||||||
|
.unwrap_or_else(|| "0".into());
|
||||||
|
let parent = if parent_sid == "0" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
resolve(ctx, &parent_sid).await?
|
||||||
|
};
|
||||||
|
if parent_sid != "0" && parent.is_none() {
|
||||||
|
return Ok(fs_status(&root, 5));
|
||||||
|
}
|
||||||
|
let res: Result<Element> = async {
|
||||||
|
match cmd.as_str() {
|
||||||
|
"FolderCreate" => {
|
||||||
|
if name.trim().is_empty() || name.len() > 256 {
|
||||||
|
return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "10"));
|
||||||
|
}
|
||||||
|
if st
|
||||||
|
.folders
|
||||||
|
.values()
|
||||||
|
.any(|(p, n, _)| p == &parent_sid && n.eq_ignore_ascii_case(&name))
|
||||||
|
{
|
||||||
|
return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "2"));
|
||||||
|
}
|
||||||
|
let ftype: u8 = req.child_u32("FolderHierarchy:Type").unwrap_or(12) as u8;
|
||||||
|
let f = ctx
|
||||||
|
.sess
|
||||||
|
.create_folder(parent.as_ref(), name.trim(), ftype)
|
||||||
|
.await?;
|
||||||
|
let sid = short_id(ctx, &f.id).await?;
|
||||||
|
st.folders
|
||||||
|
.insert(sid.clone(), (parent_sid.clone(), f.name.clone(), f.ftype));
|
||||||
|
state::save(ctx, HIERARCHY, uuid, counter + 1, &st).await?;
|
||||||
|
db::audit(&ctx.app.db, ctx.user(), "folder_create", &f.id, &ctx.ip, "").await;
|
||||||
|
Ok(Element::new(&root)
|
||||||
|
.with_text("FolderHierarchy:Status", "1")
|
||||||
|
.with_text(
|
||||||
|
"FolderHierarchy:SyncKey",
|
||||||
|
state::make_key(uuid, counter + 1),
|
||||||
|
)
|
||||||
|
.with_text("FolderHierarchy:ServerId", sid))
|
||||||
|
}
|
||||||
|
"FolderUpdate" | "FolderDelete" => {
|
||||||
|
let sid = req
|
||||||
|
.child_text("FolderHierarchy:ServerId")
|
||||||
|
.unwrap_or_default();
|
||||||
|
let Some(folder) = resolve(ctx, &sid).await? else {
|
||||||
|
return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "4"));
|
||||||
|
};
|
||||||
|
if cmd == "FolderDelete" {
|
||||||
|
if folder.ftype != crate::backend::FT_USER_MAIL
|
||||||
|
&& !matches!(folder.ftype, 13..=15)
|
||||||
|
{
|
||||||
|
return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "3"));
|
||||||
|
}
|
||||||
|
ctx.sess.delete_folder(&folder).await?;
|
||||||
|
st.folders.remove(&sid);
|
||||||
|
db::state_delete_collection(&ctx.app.db, ctx.dev.id, &sid).await?;
|
||||||
|
db::audit(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.user(),
|
||||||
|
"folder_delete",
|
||||||
|
&folder.id,
|
||||||
|
&ctx.ip,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
if folder.ftype != crate::backend::FT_USER_MAIL
|
||||||
|
&& !matches!(folder.ftype, 13..=15)
|
||||||
|
{
|
||||||
|
return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "3"));
|
||||||
|
}
|
||||||
|
let nf = ctx
|
||||||
|
.sess
|
||||||
|
.rename_folder(&folder, parent.as_ref(), name.trim())
|
||||||
|
.await?;
|
||||||
|
if nf.id != folder.id {
|
||||||
|
rename_mapping(ctx, &folder.id, &nf.id).await?;
|
||||||
|
}
|
||||||
|
st.folders
|
||||||
|
.insert(sid.clone(), (parent_sid.clone(), nf.name.clone(), nf.ftype));
|
||||||
|
db::audit(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.user(),
|
||||||
|
"folder_update",
|
||||||
|
&nf.id,
|
||||||
|
&ctx.ip,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
state::save(ctx, HIERARCHY, uuid, counter + 1, &st).await?;
|
||||||
|
Ok(Element::new(&root)
|
||||||
|
.with_text("FolderHierarchy:Status", "1")
|
||||||
|
.with_text(
|
||||||
|
"FolderHierarchy:SyncKey",
|
||||||
|
state::make_key(uuid, counter + 1),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => Ok(Element::new(&root).with_text("FolderHierarchy:Status", "10")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(e) => Ok(Reply::Wbxml(e)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(user = %ctx.user(), "{cmd} failed: {e:#}");
|
||||||
|
ctx.status = format!("error: {e}");
|
||||||
|
let m = e.to_string().to_ascii_lowercase();
|
||||||
|
let code = if m.contains("alreadyexists") || m.contains("already exists") {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
6
|
||||||
|
};
|
||||||
|
Ok(fs_status(&root, code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After an IMAP rename, move the short-id mapping of the folder and all its children.
|
||||||
|
async fn rename_mapping(ctx: &Ctx, old: &str, new: &str) -> Result<()> {
|
||||||
|
let rows = sqlx::query("SELECT backend_id FROM folder_map WHERE username = ?")
|
||||||
|
.bind(ctx.user())
|
||||||
|
.fetch_all(&ctx.app.db)
|
||||||
|
.await?;
|
||||||
|
for r in rows {
|
||||||
|
let bid: String = r.get(0);
|
||||||
|
if bid == old {
|
||||||
|
db::folder_rename_backend(&ctx.app.db, ctx.user(), &bid, new).await?;
|
||||||
|
} else if let Some(rest) = bid.strip_prefix(old) {
|
||||||
|
if rest.starts_with(['/', '.']) {
|
||||||
|
db::folder_rename_backend(&ctx.app.db, ctx.user(), &bid, &format!("{new}{rest}"))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy AS 1.0/2.0 GetHierarchy.
|
||||||
|
pub async fn get_hierarchy(ctx: &mut Ctx) -> Result<Reply> {
|
||||||
|
let (st, _) = current_hierarchy(ctx).await?;
|
||||||
|
let mut root = Element::new("FolderHierarchy:Folders");
|
||||||
|
for (sid, (p, n, t)) in st.folders {
|
||||||
|
root.push(folder_el("FolderHierarchy:Folder", &sid, &p, &n, t));
|
||||||
|
}
|
||||||
|
Ok(Reply::Wbxml(root))
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
//! ItemOperations and GetAttachment.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
use super::convert::{email, Options};
|
||||||
|
use super::folders::resolve;
|
||||||
|
use super::sync::{convert_raw, parse_server_id};
|
||||||
|
use super::{Ctx, Reply};
|
||||||
|
use crate::backend::Kind;
|
||||||
|
use crate::wbxml::{self, Element};
|
||||||
|
|
||||||
|
/// Load one attachment by FileReference. Returns (name, content type, bytes).
|
||||||
|
async fn load_attachment(ctx: &mut Ctx, fref: &str) -> Result<(String, String, Vec<u8>)> {
|
||||||
|
let (col, uid, idx) =
|
||||||
|
email::parse_file_reference(fref).ok_or_else(|| anyhow!("invalid file reference"))?;
|
||||||
|
let folder = resolve(ctx, &col)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("not found"))?;
|
||||||
|
if folder.kind() != Kind::Mail {
|
||||||
|
return Err(anyhow!("not found"));
|
||||||
|
}
|
||||||
|
let m = ctx
|
||||||
|
.sess
|
||||||
|
.fetch_mail(folder.path(), uid)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("not found"))?;
|
||||||
|
email::attachment(&m.body, idx).ok_or_else(|| anyhow!("not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachments_allowed(ctx: &Ctx) -> bool {
|
||||||
|
!ctx.s.provisioning.enabled || ctx.s.policy_for(ctx.user()).attenabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_att(ctx: &Ctx) -> usize {
|
||||||
|
let mut m = (ctx.s.sync.max_attachment_mb * 1024 * 1024) as usize;
|
||||||
|
if ctx.s.provisioning.enabled {
|
||||||
|
let p = ctx.s.policy_for(ctx.user()).maxattsize;
|
||||||
|
if p > 0 {
|
||||||
|
m = m.min(p as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_attachment(ctx: &mut Ctx) -> Result<Reply> {
|
||||||
|
let name = ctx.req.attachment_name.clone().unwrap_or_default();
|
||||||
|
if !attachments_allowed(ctx) {
|
||||||
|
return Ok(Reply::Status(axum::http::StatusCode::FORBIDDEN));
|
||||||
|
}
|
||||||
|
match load_attachment(ctx, &name).await {
|
||||||
|
Ok((_, _, data)) if data.len() > max_att(ctx) => {
|
||||||
|
Ok(Reply::Status(axum::http::StatusCode::PAYLOAD_TOO_LARGE))
|
||||||
|
}
|
||||||
|
Ok((_, _ct, data)) => {
|
||||||
|
ctx.items_out += 1;
|
||||||
|
Ok(Reply::Raw("application/octet-stream", data))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
ctx.status = format!("attachment: {e}");
|
||||||
|
Ok(Reply::Status(axum::http::StatusCode::NOT_FOUND))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_range(r: &str, total: usize) -> Option<(usize, usize)> {
|
||||||
|
let (a, b) = r.split_once('-')?;
|
||||||
|
let a: usize = a.trim().parse().ok()?;
|
||||||
|
let b: usize = b.trim().parse().ok()?;
|
||||||
|
if a > b || a >= total.max(1) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((a, b.min(total.saturating_sub(1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn item_operations(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||||
|
let Some(req) = req else {
|
||||||
|
return Ok(Reply::Wbxml(
|
||||||
|
Element::new("ItemOperations:ItemOperations").with_text("ItemOperations:Status", "2"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let multipart = ctx.req.accept_multipart;
|
||||||
|
let mut parts: Vec<Vec<u8>> = Vec::new();
|
||||||
|
let mut response = Element::new("ItemOperations:Response");
|
||||||
|
for op in &req.children {
|
||||||
|
match op.tag() {
|
||||||
|
"Fetch" => {
|
||||||
|
let mut f = Element::new("ItemOperations:Fetch");
|
||||||
|
let opts = op
|
||||||
|
.child("ItemOperations:Options")
|
||||||
|
.map(Options::parse)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let range = op
|
||||||
|
.child("ItemOperations:Options")
|
||||||
|
.and_then(|o| o.child_text("ItemOperations:Range"));
|
||||||
|
if let Some(fref) = op.child_text("AirSyncBase:FileReference") {
|
||||||
|
if !attachments_allowed(ctx) {
|
||||||
|
f.push_text("ItemOperations:Status", "16");
|
||||||
|
f.push_text("AirSyncBase:FileReference", fref);
|
||||||
|
response.push(f);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match load_attachment(ctx, &fref).await {
|
||||||
|
Ok((_, ct, data)) if data.len() > max_att(ctx) => {
|
||||||
|
let _ = ct;
|
||||||
|
f.push_text("ItemOperations:Status", "11");
|
||||||
|
f.push_text("AirSyncBase:FileReference", fref);
|
||||||
|
}
|
||||||
|
Ok((_, ct, data)) => {
|
||||||
|
let total = data.len();
|
||||||
|
let (slice, rng) =
|
||||||
|
match range.as_deref().and_then(|r| parse_range(r, total)) {
|
||||||
|
Some((a, b)) => (
|
||||||
|
&data[a..=b.min(total.saturating_sub(1))],
|
||||||
|
Some(format!("{a}-{b}")),
|
||||||
|
),
|
||||||
|
None if range.is_some() && total > 0 => {
|
||||||
|
f.push_text("ItemOperations:Status", "8");
|
||||||
|
f.push_text("AirSyncBase:FileReference", fref);
|
||||||
|
response.push(f);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
None => (&data[..], None),
|
||||||
|
};
|
||||||
|
f.push_text("ItemOperations:Status", "1");
|
||||||
|
f.push_text("AirSyncBase:FileReference", fref);
|
||||||
|
let mut props = Element::new("ItemOperations:Properties");
|
||||||
|
props.push_text("AirSyncBase:ContentType", ct);
|
||||||
|
if let Some(r) = rng {
|
||||||
|
props.push_text("ItemOperations:Range", r);
|
||||||
|
props.push_text("ItemOperations:Total", total.to_string());
|
||||||
|
}
|
||||||
|
if multipart {
|
||||||
|
parts.push(slice.to_vec());
|
||||||
|
props.push_text("ItemOperations:Part", parts.len().to_string());
|
||||||
|
} else {
|
||||||
|
props.push_text(
|
||||||
|
"ItemOperations:Data",
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(slice),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
f.push(props);
|
||||||
|
ctx.items_out += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::info!(user = %ctx.user(), "attachment fetch failed: {e:#}");
|
||||||
|
f.push_text(
|
||||||
|
"ItemOperations:Status",
|
||||||
|
if e.to_string().contains("invalid") {
|
||||||
|
"15"
|
||||||
|
} else {
|
||||||
|
"6"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
f.push_text("AirSyncBase:FileReference", fref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.push(f);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// item fetch (by CollectionId + ServerId, or LongId from Search)
|
||||||
|
let (col, sid) = match (
|
||||||
|
op.child_text("AirSync:CollectionId"),
|
||||||
|
op.child_text("AirSync:ServerId"),
|
||||||
|
op.child_text("Search:LongId"),
|
||||||
|
) {
|
||||||
|
(Some(c), Some(s), _) => (c, s),
|
||||||
|
(_, _, Some(l)) => (super::sync::collection_of(&l).to_string(), l),
|
||||||
|
_ => {
|
||||||
|
f.push_text("ItemOperations:Status", "2");
|
||||||
|
response.push(f);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let res: Result<Element> = async {
|
||||||
|
let folder = resolve(ctx, &col)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("not found"))?;
|
||||||
|
let id = parse_server_id(ctx, &sid, &folder.id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("not found"))?;
|
||||||
|
let raw = ctx
|
||||||
|
.sess
|
||||||
|
.fetch(&folder, std::slice::from_ref(&id))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow!("not found"))?;
|
||||||
|
let app = convert_raw(ctx, &col, folder.kind(), &raw, &opts)
|
||||||
|
.ok_or_else(|| anyhow!("conversion failed"))?;
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(app) => {
|
||||||
|
f.push_text("ItemOperations:Status", "1");
|
||||||
|
if op.has("Search:LongId") {
|
||||||
|
f.push_text("Search:LongId", sid.clone());
|
||||||
|
} else {
|
||||||
|
f.push_text("AirSync:CollectionId", col.clone());
|
||||||
|
f.push_text("AirSync:ServerId", sid.clone());
|
||||||
|
}
|
||||||
|
let mut props = Element::new("ItemOperations:Properties");
|
||||||
|
props.children = app.children;
|
||||||
|
f.push(props);
|
||||||
|
ctx.items_out += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
f.push_text(
|
||||||
|
"ItemOperations:Status",
|
||||||
|
if e.to_string().contains("not found") {
|
||||||
|
"6"
|
||||||
|
} else {
|
||||||
|
"3"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
f.push_text("AirSync:CollectionId", col);
|
||||||
|
f.push_text("AirSync:ServerId", sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.push(f);
|
||||||
|
}
|
||||||
|
"EmptyFolderContents" => {
|
||||||
|
let col = op.child_text("AirSync:CollectionId").unwrap_or_default();
|
||||||
|
let mut e = Element::new("ItemOperations:EmptyFolderContents");
|
||||||
|
let res: Result<()> = async {
|
||||||
|
let folder = resolve(ctx, &col)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("not found"))?;
|
||||||
|
match folder.kind() {
|
||||||
|
Kind::Mail => ctx.sess.mail_empty(&folder).await,
|
||||||
|
_ => {
|
||||||
|
let l = ctx.sess.list(&folder, None).await?;
|
||||||
|
for (id, _) in l.items {
|
||||||
|
ctx.sess.dav_delete(&folder, &id).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
e.push_text("ItemOperations:Status", if res.is_ok() { "1" } else { "3" });
|
||||||
|
e.push_text("AirSync:CollectionId", col);
|
||||||
|
response.push(e);
|
||||||
|
}
|
||||||
|
"Move" => {
|
||||||
|
let mut m = Element::new("ItemOperations:Move");
|
||||||
|
m.push_text("ItemOperations:Status", "2");
|
||||||
|
if let Some(c) = op.child("ItemOperations:ConversationId") {
|
||||||
|
m.push(c.clone());
|
||||||
|
}
|
||||||
|
response.push(m);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let root = Element::new("ItemOperations:ItemOperations")
|
||||||
|
.with_text("ItemOperations:Status", "1")
|
||||||
|
.with(response);
|
||||||
|
if multipart {
|
||||||
|
let wb = wbxml::encode(&root);
|
||||||
|
let mut all = vec![wb];
|
||||||
|
all.extend(parts);
|
||||||
|
let n = all.len();
|
||||||
|
let mut body = Vec::new();
|
||||||
|
body.extend_from_slice(&(n as i32).to_le_bytes());
|
||||||
|
let mut offset = 4 + n * 8;
|
||||||
|
for p in &all {
|
||||||
|
body.extend_from_slice(&(offset as i32).to_le_bytes());
|
||||||
|
body.extend_from_slice(&(p.len() as i32).to_le_bytes());
|
||||||
|
offset += p.len();
|
||||||
|
}
|
||||||
|
for p in all {
|
||||||
|
body.extend_from_slice(&p);
|
||||||
|
}
|
||||||
|
return Ok(Reply::Raw("application/vnd.ms-sync.multipart", body));
|
||||||
|
}
|
||||||
|
Ok(Reply::Wbxml(root))
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
//! SendMail, SmartReply, SmartForward, MeetingResponse.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use mail_parser::{MessageParser, MimeHeaders};
|
||||||
|
|
||||||
|
use super::convert::{calendar, email};
|
||||||
|
use super::folders::resolve;
|
||||||
|
use super::sync::{parse_server_id, server_id};
|
||||||
|
use super::{Ctx, Reply};
|
||||||
|
use crate::backend::{mime, smtp, Kind, FT_CALENDAR, FT_SENT};
|
||||||
|
use crate::db;
|
||||||
|
use crate::util::{html_escape, html_to_text};
|
||||||
|
use crate::wbxml::Element;
|
||||||
|
|
||||||
|
fn fail(ctx: &mut Ctx, status: u32, why: &str) -> Reply {
|
||||||
|
ctx.status = format!("error: {why}");
|
||||||
|
tracing::warn!(user = %ctx.user(), cmd = %ctx.req.cmd, "send failed: {why}");
|
||||||
|
if ctx.req.ver() >= 140 {
|
||||||
|
Reply::Wbxml(
|
||||||
|
Element::new(&format!("ComposeMail:{}", ctx.req.cmd))
|
||||||
|
.with_text("ComposeMail:Status", status.to_string()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Reply::Status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addrs(m: &mail_parser::Message, out: &mut Vec<String>) {
|
||||||
|
for a in [m.to(), m.cc(), m.bcc()].into_iter().flatten() {
|
||||||
|
for x in a.iter() {
|
||||||
|
if let Some(ad) = x.address() {
|
||||||
|
let ad = ad.trim().to_string();
|
||||||
|
if ad.contains('@') && !out.iter().any(|o| o.eq_ignore_ascii_case(&ad)) {
|
||||||
|
out.push(ad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_mail(ctx: &mut Ctx, req: Option<Element>, body: &[u8]) -> Result<Reply> {
|
||||||
|
let cmd = ctx.req.cmd.clone();
|
||||||
|
// ---- collect parameters (WBXML for AS 14+, query + raw MIME below)
|
||||||
|
let (mime_raw, save_sent, src_folder, src_item, src_long, replace_mime) = match &req {
|
||||||
|
Some(r) => {
|
||||||
|
let mime = r
|
||||||
|
.child("ComposeMail:MIME")
|
||||||
|
.map(|m| m.bytes_value())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let src = r.child("ComposeMail:Source");
|
||||||
|
(
|
||||||
|
mime,
|
||||||
|
r.has("ComposeMail:SaveInSentItems"),
|
||||||
|
src.and_then(|s| s.child_text("ComposeMail:FolderId")),
|
||||||
|
src.and_then(|s| s.child_text("ComposeMail:ItemId")),
|
||||||
|
src.and_then(|s| s.child_text("ComposeMail:LongId")),
|
||||||
|
r.has("ComposeMail:ReplaceMime"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => (
|
||||||
|
body.to_vec(),
|
||||||
|
ctx.req.save_in_sent,
|
||||||
|
ctx.req.collection_id.clone(),
|
||||||
|
ctx.req.item_id.clone(),
|
||||||
|
ctx.req.long_id.clone(),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if mime_raw.is_empty() {
|
||||||
|
return Ok(fail(ctx, 107, "empty MIME"));
|
||||||
|
}
|
||||||
|
let Some(msg) = MessageParser::default().parse(&mime_raw) else {
|
||||||
|
return Ok(fail(ctx, 107, "invalid MIME"));
|
||||||
|
};
|
||||||
|
let mut rcpts = Vec::new();
|
||||||
|
addrs(&msg, &mut rcpts);
|
||||||
|
if rcpts.is_empty() {
|
||||||
|
return Ok(fail(ctx, 107, "no recipients"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- original message for smart reply / forward
|
||||||
|
let mut original: Option<(crate::backend::BFolder, u32, Vec<u8>)> = None;
|
||||||
|
if cmd != "SendMail" {
|
||||||
|
let (fid, iid) = if let Some(l) = src_long.clone() {
|
||||||
|
let mut p = l.splitn(2, ':');
|
||||||
|
(p.next().map(|s| s.to_string()), Some(l))
|
||||||
|
} else {
|
||||||
|
(src_folder.clone(), src_item.clone())
|
||||||
|
};
|
||||||
|
if let (Some(fid), Some(iid)) = (fid, iid) {
|
||||||
|
let iid = if iid.contains(':') {
|
||||||
|
iid
|
||||||
|
} else {
|
||||||
|
format!("{fid}:{iid}")
|
||||||
|
};
|
||||||
|
if let Some(folder) = resolve(ctx, &fid).await? {
|
||||||
|
if let Some(uid) = parse_server_id(ctx, &iid, &folder.id)
|
||||||
|
.await?
|
||||||
|
.and_then(|u| u.parse::<u32>().ok())
|
||||||
|
{
|
||||||
|
if let Ok(m) = ctx.sess.fetch_mail(folder.path(), uid).await {
|
||||||
|
original = Some((folder, uid, m.body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if original.is_none() {
|
||||||
|
tracing::info!(user = %ctx.user(), "smart reply/forward source not found, sending as is");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- compose
|
||||||
|
let from_addr = ctx.sess.email.clone();
|
||||||
|
let mut out: Vec<u8> = match (&original, replace_mime) {
|
||||||
|
(Some((_, _, orig)), false) => {
|
||||||
|
compose_smart(ctx, &msg, &mime_raw, orig, cmd == "SmartForward")?
|
||||||
|
}
|
||||||
|
_ => mime_raw.clone(),
|
||||||
|
};
|
||||||
|
// sender handling
|
||||||
|
let from_hdr = msg
|
||||||
|
.from()
|
||||||
|
.and_then(|f| f.first())
|
||||||
|
.and_then(|a| a.address())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
match from_hdr {
|
||||||
|
None => {
|
||||||
|
out = mime::prepend_headers(&out, &[("From".into(), from_addr.clone())]);
|
||||||
|
}
|
||||||
|
Some(f) if ctx.s.smtp.enforce_from && !f.eq_ignore_ascii_case(&from_addr) => {
|
||||||
|
let name = msg
|
||||||
|
.from()
|
||||||
|
.and_then(|f| f.first())
|
||||||
|
.and_then(|a| a.name())
|
||||||
|
.map(|n| n.to_string());
|
||||||
|
out = mime::strip_headers(&out, &["From"]);
|
||||||
|
let v = match name {
|
||||||
|
Some(n) => format!("{} <{}>", mime::encode_word(&n), from_addr),
|
||||||
|
None => from_addr.clone(),
|
||||||
|
};
|
||||||
|
out = mime::prepend_headers(&out, &[("From".into(), v)]);
|
||||||
|
tracing::info!(user = %ctx.user(), "From header {f} replaced with {from_addr}");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if msg.date().is_none() {
|
||||||
|
out = mime::prepend_headers(&out, &[("Date".into(), chrono::Utc::now().to_rfc2822())]);
|
||||||
|
}
|
||||||
|
if msg.message_id().is_none() {
|
||||||
|
let dom = from_addr.split('@').nth(1).unwrap_or("activepush.local");
|
||||||
|
out = mime::prepend_headers(
|
||||||
|
&out,
|
||||||
|
&[(
|
||||||
|
"Message-ID".into(),
|
||||||
|
format!("<{}@{}>", uuid::Uuid::new_v4(), dom),
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let wire = mime::strip_headers(&out, &["Bcc"]);
|
||||||
|
|
||||||
|
let password = ctx.sess.password().to_string();
|
||||||
|
if let Err(e) = smtp::send(
|
||||||
|
&ctx.s.smtp,
|
||||||
|
&ctx.sess.login,
|
||||||
|
&password,
|
||||||
|
&from_addr,
|
||||||
|
&rcpts,
|
||||||
|
&wire,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Ok(fail(ctx, 110, &format!("{e:#}")));
|
||||||
|
}
|
||||||
|
ctx.app.metrics.mail_sent();
|
||||||
|
ctx.items_in += 1;
|
||||||
|
tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, recipients = rcpts.len(), bytes = wire.len(), "{cmd} delivered to SMTP");
|
||||||
|
|
||||||
|
if save_sent && ctx.s.smtp.save_in_sent {
|
||||||
|
match ctx.sess.special_folder(FT_SENT).await {
|
||||||
|
Ok(Some(sent)) => {
|
||||||
|
if let Err(e) = ctx.sess.mail_append(sent.path(), "\\Seen", &out).await {
|
||||||
|
tracing::warn!(user = %ctx.user(), "saving to Sent failed: {e:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => tracing::warn!(user = %ctx.user(), "no Sent folder found; copy not saved"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((folder, uid, _)) = &original {
|
||||||
|
let flag = if cmd == "SmartForward" {
|
||||||
|
"$Forwarded"
|
||||||
|
} else {
|
||||||
|
"\\Answered"
|
||||||
|
};
|
||||||
|
let _ = ctx.sess.mail_add_flag(folder.path(), *uid, flag).await;
|
||||||
|
}
|
||||||
|
Ok(Reply::Empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compose_smart(
|
||||||
|
ctx: &Ctx,
|
||||||
|
msg: &mail_parser::Message,
|
||||||
|
raw: &[u8],
|
||||||
|
orig_raw: &[u8],
|
||||||
|
forward: bool,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let orig = MessageParser::default()
|
||||||
|
.parse(orig_raw)
|
||||||
|
.ok_or_else(|| anyhow!("original message unparseable"))?;
|
||||||
|
// headers from the client message (minus MIME structure headers)
|
||||||
|
let (hblock, _) = mime::split_headers(raw);
|
||||||
|
let mut headers: Vec<(String, String)> = mime::header_lines(hblock)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(k, _)| {
|
||||||
|
![
|
||||||
|
"content-type",
|
||||||
|
"content-transfer-encoding",
|
||||||
|
"mime-version",
|
||||||
|
"content-disposition",
|
||||||
|
]
|
||||||
|
.contains(&k.to_ascii_lowercase().as_str())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !forward {
|
||||||
|
if let Some(mid) = orig.message_id() {
|
||||||
|
if !headers
|
||||||
|
.iter()
|
||||||
|
.any(|(k, _)| k.eq_ignore_ascii_case("In-Reply-To"))
|
||||||
|
{
|
||||||
|
headers.push(("In-Reply-To".into(), format!("<{mid}>")));
|
||||||
|
}
|
||||||
|
if !headers
|
||||||
|
.iter()
|
||||||
|
.any(|(k, _)| k.eq_ignore_ascii_case("References"))
|
||||||
|
{
|
||||||
|
let mut refs: Vec<String> = orig
|
||||||
|
.references()
|
||||||
|
.as_text_list()
|
||||||
|
.map(|l| l.iter().map(|r| format!("<{r}>")).collect())
|
||||||
|
.or_else(|| orig.references().as_text().map(|r| vec![format!("<{r}>")]))
|
||||||
|
.unwrap_or_default();
|
||||||
|
refs.push(format!("<{mid}>"));
|
||||||
|
headers.push(("References".into(), refs.join(" ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let new_text = msg.body_text(0).map(|s| s.into_owned()).unwrap_or_default();
|
||||||
|
let new_html = if msg.html_body_count() > 0
|
||||||
|
&& msg
|
||||||
|
.html_bodies()
|
||||||
|
.any(|p| matches!(p.body, mail_parser::PartType::Html(_)))
|
||||||
|
{
|
||||||
|
msg.body_html(0).map(|s| s.into_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let o_text = orig
|
||||||
|
.body_text(0)
|
||||||
|
.map(|s| s.into_owned())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
orig.body_html(0)
|
||||||
|
.map(|h| html_to_text(&h))
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
let o_html = if orig.html_body_count() > 0
|
||||||
|
&& orig
|
||||||
|
.html_bodies()
|
||||||
|
.any(|p| matches!(p.body, mail_parser::PartType::Html(_)))
|
||||||
|
{
|
||||||
|
orig.body_html(0).map(|s| s.into_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let addr = |a: Option<&mail_parser::Address>| {
|
||||||
|
a.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.map(|x| match (x.name(), x.address()) {
|
||||||
|
(Some(n), Some(ad)) => format!("{n} <{ad}>"),
|
||||||
|
(None, Some(ad)) => ad.to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let date = orig.date().map(|d| d.to_rfc822()).unwrap_or_default();
|
||||||
|
let subj = orig.subject().unwrap_or("").to_string();
|
||||||
|
let from = addr(orig.from());
|
||||||
|
let to = addr(orig.to());
|
||||||
|
let (text, html, mut atts) = if forward && !ctx.s.imap.inline_forward {
|
||||||
|
(
|
||||||
|
new_text.clone(),
|
||||||
|
new_html.clone(),
|
||||||
|
vec![mime::Attachment {
|
||||||
|
name: format!("{}.eml", if subj.is_empty() { "message" } else { &subj }),
|
||||||
|
content_type: "message/rfc822".into(),
|
||||||
|
data: orig_raw.to_vec(),
|
||||||
|
content_id: None,
|
||||||
|
inline: false,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
} else if forward {
|
||||||
|
let head = format!("-------- Original Message --------\nSubject: {subj}\nDate: {date}\nFrom: {from}\nTo: {to}\n\n");
|
||||||
|
let text = format!("{new_text}\n\n{head}{o_text}");
|
||||||
|
let html = new_html.clone().map(|nh| {
|
||||||
|
let oh = o_html.clone().unwrap_or_else(|| format!("<pre>{}</pre>", html_escape(&o_text)));
|
||||||
|
format!(
|
||||||
|
"{nh}<br><div>-------- Original Message --------<br>Subject: {}<br>Date: {}<br>From: {}<br>To: {}<br><br>{oh}</div>",
|
||||||
|
html_escape(&subj),
|
||||||
|
html_escape(&date),
|
||||||
|
html_escape(&from),
|
||||||
|
html_escape(&to)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let mut a = Vec::new();
|
||||||
|
for (i, part) in orig.attachments().enumerate() {
|
||||||
|
if let Some((name, ct, data)) = email::attachment(orig_raw, i) {
|
||||||
|
a.push(mime::Attachment {
|
||||||
|
name,
|
||||||
|
content_type: ct,
|
||||||
|
data,
|
||||||
|
content_id: part.content_id().map(|s| s.to_string()),
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(text, html, a)
|
||||||
|
} else {
|
||||||
|
let quoted: String = o_text.lines().map(|l| format!("> {l}\n")).collect();
|
||||||
|
let text = format!("{new_text}\n\nOn {date}, {from} wrote:\n{quoted}");
|
||||||
|
let html = new_html.clone().map(|nh| {
|
||||||
|
let oh = o_html.clone().unwrap_or_else(|| format!("<pre>{}</pre>", html_escape(&o_text)));
|
||||||
|
format!(
|
||||||
|
"{nh}<br><div>On {}, {} wrote:</div><blockquote style=\"margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex\">{oh}</blockquote>",
|
||||||
|
html_escape(&date),
|
||||||
|
html_escape(&from)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
(text, html, Vec::new())
|
||||||
|
};
|
||||||
|
// attachments the client added itself
|
||||||
|
for (i, part) in msg.attachments().enumerate() {
|
||||||
|
if let Some((name, ct, data)) = email::attachment(raw, i) {
|
||||||
|
atts.push(mime::Attachment {
|
||||||
|
name,
|
||||||
|
content_type: ct,
|
||||||
|
data,
|
||||||
|
content_id: part.content_id().map(|s| s.to_string()),
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(mime::build(&headers, Some(&text), html.as_deref(), &atts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ MeetingResponse
|
||||||
|
|
||||||
|
pub async fn meeting_response(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||||
|
let mut root = Element::new("MeetingResponse:MeetingResponse");
|
||||||
|
let Some(req) = req else {
|
||||||
|
return Ok(Reply::Wbxml(root));
|
||||||
|
};
|
||||||
|
for r in req.children_named("MeetingResponse:Request") {
|
||||||
|
let resp_code = r.child_u32("MeetingResponse:UserResponse").unwrap_or(0);
|
||||||
|
let col = r
|
||||||
|
.child_text("MeetingResponse:CollectionId")
|
||||||
|
.unwrap_or_default();
|
||||||
|
let rid = r
|
||||||
|
.child_text("MeetingResponse:RequestId")
|
||||||
|
.or_else(|| r.child_text("Search:LongId"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut res = Element::new("MeetingResponse:Result")
|
||||||
|
.with_text("MeetingResponse:RequestId", rid.clone());
|
||||||
|
let outcome: Result<Option<String>> = async {
|
||||||
|
let col = if col.is_empty() {
|
||||||
|
super::sync::collection_of(&rid).to_string()
|
||||||
|
} else {
|
||||||
|
col.clone()
|
||||||
|
};
|
||||||
|
let folder = resolve(ctx, &col)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("collection not found"))?;
|
||||||
|
let sid = if rid.contains(':') {
|
||||||
|
rid.clone()
|
||||||
|
} else {
|
||||||
|
format!("{col}:{rid}")
|
||||||
|
};
|
||||||
|
match folder.kind() {
|
||||||
|
Kind::Mail => {
|
||||||
|
let uid: u32 = parse_server_id(ctx, &sid, &folder.id)
|
||||||
|
.await?
|
||||||
|
.and_then(|u| u.parse().ok())
|
||||||
|
.ok_or_else(|| anyhow!("item not found"))?;
|
||||||
|
let m = ctx.sess.fetch_mail(folder.path(), uid).await?;
|
||||||
|
let ics =
|
||||||
|
email::calendar_of(&m.body).ok_or_else(|| anyhow!("no calendar part"))?;
|
||||||
|
let partstat = match resp_code {
|
||||||
|
1 => "ACCEPTED",
|
||||||
|
2 => "TENTATIVE",
|
||||||
|
_ => "DECLINED",
|
||||||
|
};
|
||||||
|
let mut cal_id = None;
|
||||||
|
if resp_code != 3 && ctx.s.caldav.enabled {
|
||||||
|
if let Some(cal) = ctx.sess.special_folder(FT_CALENDAR).await? {
|
||||||
|
let updated = calendar::set_partstat(&ics, &ctx.sess.email, partstat)
|
||||||
|
.ok_or_else(|| anyhow!("bad calendar data"))?;
|
||||||
|
let (id, _) = ctx.sess.dav_put(&cal, None, updated, None).await?;
|
||||||
|
let cal_sid = folder_short(ctx, &cal.id).await?;
|
||||||
|
cal_id = Some(server_id(ctx, &cal_sid, &cal.id, &id).await?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the device sends the reply e-mail itself; remove the invitation like Z-Push
|
||||||
|
let _ = ctx.sess.mail_delete(&folder, uid, true).await;
|
||||||
|
Ok(cal_id)
|
||||||
|
}
|
||||||
|
Kind::Calendar => {
|
||||||
|
let id = parse_server_id(ctx, &sid, &folder.id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("item not found"))?;
|
||||||
|
let cur = ctx.sess.fetch_dav(&folder, &id).await?;
|
||||||
|
let partstat = match resp_code {
|
||||||
|
1 => "ACCEPTED",
|
||||||
|
2 => "TENTATIVE",
|
||||||
|
_ => "DECLINED",
|
||||||
|
};
|
||||||
|
let updated = calendar::set_partstat(
|
||||||
|
cur.data.as_deref().unwrap_or(""),
|
||||||
|
&ctx.sess.email,
|
||||||
|
partstat,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| anyhow!("bad calendar data"))?;
|
||||||
|
ctx.sess
|
||||||
|
.dav_put(&folder, Some(&id), updated, Some(&cur.etag))
|
||||||
|
.await?;
|
||||||
|
Ok(Some(sid))
|
||||||
|
}
|
||||||
|
_ => Err(anyhow!("invalid collection")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match outcome {
|
||||||
|
Ok(cal) => {
|
||||||
|
res.push_text("MeetingResponse:Status", "1");
|
||||||
|
if let Some(c) = cal {
|
||||||
|
if resp_code != 3 {
|
||||||
|
res.push_text("MeetingResponse:CalendarId", c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db::audit(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.user(),
|
||||||
|
"meeting_response",
|
||||||
|
&rid,
|
||||||
|
&ctx.ip,
|
||||||
|
&resp_code.to_string(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(user = %ctx.user(), "MeetingResponse failed: {e:#}");
|
||||||
|
res.push_text(
|
||||||
|
"MeetingResponse:Status",
|
||||||
|
if e.to_string().contains("not found") {
|
||||||
|
"2"
|
||||||
|
} else {
|
||||||
|
"4"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.push(res);
|
||||||
|
}
|
||||||
|
Ok(Reply::Wbxml(root))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn folder_short(ctx: &Ctx, bid: &str) -> Result<String> {
|
||||||
|
Ok(db::folder_short_id(&ctx.app.db, ctx.user(), bid)
|
||||||
|
.await?
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
//! Settings, Search (GAL + Mailbox), ResolveRecipients.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use super::convert::{contact, Options};
|
||||||
|
use super::folders::resolve;
|
||||||
|
use super::provision::device_info_from;
|
||||||
|
use super::sync::server_id;
|
||||||
|
use super::{Ctx, Reply};
|
||||||
|
use crate::backend::{dav, imap, Kind};
|
||||||
|
use crate::config::expand_user_template;
|
||||||
|
use crate::db::{self, DeviceField};
|
||||||
|
use crate::wbxml::Element;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Settings
|
||||||
|
|
||||||
|
pub async fn settings(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||||
|
let mut root = Element::new("Settings:Settings").with_text("Settings:Status", "1");
|
||||||
|
let Some(req) = req else {
|
||||||
|
return Ok(Reply::Wbxml(root));
|
||||||
|
};
|
||||||
|
for item in &req.children {
|
||||||
|
match item.tag() {
|
||||||
|
"Oof" => {
|
||||||
|
let mut o = Element::new("Settings:Oof");
|
||||||
|
if item.has("Settings:Get") {
|
||||||
|
o.push_text("Settings:Status", "1");
|
||||||
|
o.push(Element::new("Settings:Get").with_text("Settings:OofState", "0"));
|
||||||
|
} else {
|
||||||
|
// out-of-office is managed by the mail server (e.g. Sieve vacation)
|
||||||
|
o.push_text("Settings:Status", "2");
|
||||||
|
}
|
||||||
|
root.push(o);
|
||||||
|
}
|
||||||
|
"DeviceInformation" => {
|
||||||
|
if let Some(set) = item.child("Settings:Set") {
|
||||||
|
db::device_set_info(&ctx.app.db, ctx.dev.id, &device_info_from(set)).await?;
|
||||||
|
}
|
||||||
|
root.push(
|
||||||
|
Element::new("Settings:DeviceInformation").with_text("Settings:Status", "1"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"DevicePassword" => {
|
||||||
|
if let Some(pw) = item.path(&["Settings:Set", "Settings:Password"]) {
|
||||||
|
let v = pw.text_value();
|
||||||
|
if v.len() <= 256 {
|
||||||
|
db::device_set_field(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.dev.id,
|
||||||
|
DeviceField::RecoveryPassword,
|
||||||
|
None,
|
||||||
|
Some(&v),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db::audit(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.user(),
|
||||||
|
"recovery_password_set",
|
||||||
|
&ctx.req.device_id,
|
||||||
|
&ctx.ip,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.push(
|
||||||
|
Element::new("Settings:DevicePassword").with_text("Settings:Status", "1"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"UserInformation" => {
|
||||||
|
let email = ctx.sess.email.clone();
|
||||||
|
let mut get = Element::new("Settings:Get");
|
||||||
|
if ctx.req.ver() >= 141 {
|
||||||
|
get.push(
|
||||||
|
Element::new("Settings:Accounts").with(
|
||||||
|
Element::new("Settings:Account")
|
||||||
|
.with_text("Settings:UserDisplayName", email.clone())
|
||||||
|
.with(
|
||||||
|
Element::new("Settings:EmailAddresses")
|
||||||
|
.with_text("Settings:PrimarySmtpAddress", email.clone()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
get.push(
|
||||||
|
Element::new("Settings:EmailAddresses")
|
||||||
|
.with_text("Settings:SmtpAddress", email),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
root.push(
|
||||||
|
Element::new("Settings:UserInformation")
|
||||||
|
.with_text("Settings:Status", "1")
|
||||||
|
.with(get),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"RightsManagementInformation" => {
|
||||||
|
root.push(
|
||||||
|
Element::new("Settings:RightsManagementInformation")
|
||||||
|
.with_text("Settings:Status", "1")
|
||||||
|
.with(
|
||||||
|
Element::new("Settings:Get")
|
||||||
|
.with(Element::new("RightsManagement:RightsManagementTemplates")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Reply::Wbxml(root))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ GAL helpers
|
||||||
|
|
||||||
|
pub struct Person {
|
||||||
|
pub summary: contact::CardSummary,
|
||||||
|
pub from_gal: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_people(ctx: &mut Ctx, query: &str, limit: usize) -> Result<Vec<Person>> {
|
||||||
|
let mut out: Vec<Person> = Vec::new();
|
||||||
|
let q = query.trim();
|
||||||
|
if q.chars().count() < ctx.s.carddav.gal_min_length.max(1) {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
let Some((client, home)) = ctx.sess.carddav()? else {
|
||||||
|
return Ok(out);
|
||||||
|
};
|
||||||
|
let s = ctx.s.clone();
|
||||||
|
let mut books: Vec<(String, bool)> = Vec::new();
|
||||||
|
if !s.carddav.gal_path.is_empty() {
|
||||||
|
books.push((
|
||||||
|
dav::encode_path(&expand_user_template(
|
||||||
|
&s.carddav.gal_path,
|
||||||
|
ctx.user(),
|
||||||
|
&s.general.default_domain,
|
||||||
|
)),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Ok(cols) = client.collections(&home, dav::DavKind::AddressBook).await {
|
||||||
|
for c in cols {
|
||||||
|
let p = dav::encode_path(&c.href);
|
||||||
|
if !books.iter().any(|(b, _)| *b == p) {
|
||||||
|
books.push((p, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (b, gal) in books {
|
||||||
|
let res = match client.search_cards(&b, q).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("GAL search in {b} failed: {e:#}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for r in res {
|
||||||
|
if let Some(sm) = r.data.as_deref().and_then(contact::summary) {
|
||||||
|
if sm.emails.is_empty() && sm.display_name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dup = out.iter().any(|p| {
|
||||||
|
!sm.emails.is_empty() && p.summary.emails.first() == sm.emails.first()
|
||||||
|
});
|
||||||
|
if !dup {
|
||||||
|
out.push(Person {
|
||||||
|
summary: sm,
|
||||||
|
from_gal: gal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if out.len() >= limit {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_range(r: Option<String>, def: usize) -> (usize, usize) {
|
||||||
|
r.and_then(|r| {
|
||||||
|
let (a, b) = r.split_once('-')?;
|
||||||
|
Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
|
||||||
|
})
|
||||||
|
.filter(|(a, b)| a <= b)
|
||||||
|
.unwrap_or((0, def.saturating_sub(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Search
|
||||||
|
|
||||||
|
pub async fn search(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||||
|
let status_only =
|
||||||
|
|s: &str| Reply::Wbxml(Element::new("Search:Search").with_text("Search:Status", s));
|
||||||
|
let Some(req) = req else {
|
||||||
|
return Ok(status_only("2"));
|
||||||
|
};
|
||||||
|
let Some(store) = req.child("Search:Store") else {
|
||||||
|
return Ok(status_only("2"));
|
||||||
|
};
|
||||||
|
let name = store.child_text("Search:Name").unwrap_or_default();
|
||||||
|
let opts_el = store.child("Search:Options");
|
||||||
|
let max = ctx.s.sync.search_max_results as usize;
|
||||||
|
let (from, to) = parse_range(
|
||||||
|
opts_el.and_then(|o| o.child_text("Search:Range")),
|
||||||
|
max.min(100),
|
||||||
|
);
|
||||||
|
let to = to.min(from + max.max(1) - 1);
|
||||||
|
let mut st = Element::new("Search:Store");
|
||||||
|
let started = Instant::now();
|
||||||
|
let deadline = Duration::from_secs(ctx.s.sync.search_wait_secs.max(3));
|
||||||
|
|
||||||
|
match name.to_ascii_lowercase().as_str() {
|
||||||
|
"gal" => {
|
||||||
|
let q = store.child_text("Search:Query").unwrap_or_default();
|
||||||
|
let people = find_people(ctx, &q, to + 1).await.unwrap_or_default();
|
||||||
|
st.push_text("Search:Status", "1");
|
||||||
|
let total = people.len();
|
||||||
|
for p in people.into_iter().skip(from).take(to + 1 - from) {
|
||||||
|
let s = p.summary;
|
||||||
|
let mut props = Element::new("Search:Properties");
|
||||||
|
props.push_text(
|
||||||
|
"GAL:DisplayName",
|
||||||
|
if s.display_name.is_empty() {
|
||||||
|
s.emails.first().cloned().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
s.display_name.clone()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
props.push_opt("GAL:Phone", (!s.phone.is_empty()).then(|| s.phone.clone()));
|
||||||
|
props.push_opt(
|
||||||
|
"GAL:Office",
|
||||||
|
(!s.office.is_empty()).then(|| s.office.clone()),
|
||||||
|
);
|
||||||
|
props.push_opt("GAL:Title", (!s.title.is_empty()).then(|| s.title.clone()));
|
||||||
|
props.push_opt(
|
||||||
|
"GAL:Company",
|
||||||
|
(!s.company.is_empty()).then(|| s.company.clone()),
|
||||||
|
);
|
||||||
|
props.push_opt(
|
||||||
|
"GAL:FirstName",
|
||||||
|
(!s.first.is_empty()).then(|| s.first.clone()),
|
||||||
|
);
|
||||||
|
props.push_opt("GAL:LastName", (!s.last.is_empty()).then(|| s.last.clone()));
|
||||||
|
props.push_opt(
|
||||||
|
"GAL:MobilePhone",
|
||||||
|
(!s.mobile.is_empty()).then(|| s.mobile.clone()),
|
||||||
|
);
|
||||||
|
if let Some(e) = s.emails.first() {
|
||||||
|
props.push_text("GAL:EmailAddress", e.clone());
|
||||||
|
}
|
||||||
|
st.push(Element::new("Search:Result").with(props));
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
st.push_text(
|
||||||
|
"Search:Range",
|
||||||
|
format!(
|
||||||
|
"{from}-{}",
|
||||||
|
(from + total.min(to + 1 - from)).saturating_sub(1)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
st.push_text("Search:Total", total.to_string());
|
||||||
|
ctx.items_out += total as u64;
|
||||||
|
}
|
||||||
|
"mailbox" => {
|
||||||
|
let Some(query) = store.child("Search:Query") else {
|
||||||
|
return Ok(status_only("2"));
|
||||||
|
};
|
||||||
|
let and = query.child("Search:And").unwrap_or(query);
|
||||||
|
let text = and.child_text("Search:FreeText").unwrap_or_default();
|
||||||
|
let col = and.child_text("AirSync:CollectionId");
|
||||||
|
let since = and
|
||||||
|
.child("Search:GreaterThan")
|
||||||
|
.and_then(|g| g.child_text("Search:Value"))
|
||||||
|
.and_then(|v| crate::backend::tz::parse_eas_date(&v));
|
||||||
|
let deep = opts_el
|
||||||
|
.map(|o| o.has("Search:DeepTraversal"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let mut opts = opts_el.map(Options::parse).unwrap_or_default();
|
||||||
|
if opts.prefs.is_empty() {
|
||||||
|
opts.prefs.push(super::convert::BodyPref {
|
||||||
|
typ: 1,
|
||||||
|
trunc: Some(5120),
|
||||||
|
preview: Some(255),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// folders to search
|
||||||
|
let all = ctx.sess.folders().await?;
|
||||||
|
let mut folders = Vec::new();
|
||||||
|
match &col {
|
||||||
|
Some(c) => {
|
||||||
|
if let Some(f) = resolve(ctx, c).await? {
|
||||||
|
if deep {
|
||||||
|
let pref = f.id.clone();
|
||||||
|
for x in all.iter().filter(|x| {
|
||||||
|
x.kind() == Kind::Mail
|
||||||
|
&& (x.id == pref
|
||||||
|
|| x.id.starts_with(&format!("{pref}/"))
|
||||||
|
|| x.id.starts_with(&format!("{pref}.")))
|
||||||
|
}) {
|
||||||
|
folders.push(x.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
folders.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => folders.extend(all.iter().filter(|x| x.kind() == Kind::Mail).cloned()),
|
||||||
|
}
|
||||||
|
let mut hits: Vec<(crate::backend::BFolder, u32)> = Vec::new();
|
||||||
|
for f in folders {
|
||||||
|
if started.elapsed() > deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match ctx.sess.mail_search(&f, &text, since).await {
|
||||||
|
Ok(uids) => hits.extend(uids.into_iter().rev().map(|u| (f.clone(), u))),
|
||||||
|
Err(e) => tracing::debug!("search in {} failed: {e:#}", f.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total = hits.len();
|
||||||
|
st.push_text("Search:Status", "1");
|
||||||
|
for (f, uid) in hits.into_iter().skip(from).take(to + 1 - from) {
|
||||||
|
let Ok(m) = ctx.sess.fetch_mail(f.path(), uid).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let cid = super::folders::short_id(ctx, &f.id).await?;
|
||||||
|
let sid = server_id(ctx, &cid, &f.id, &uid.to_string()).await?;
|
||||||
|
let conv = super::convert::email::to_eas(
|
||||||
|
&m,
|
||||||
|
&super::convert::email::MailContext {
|
||||||
|
collection: &cid,
|
||||||
|
ver: ctx.req.ver(),
|
||||||
|
opts: &opts,
|
||||||
|
default_tz: ctx.s.timezone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut props = Element::new("Search:Properties");
|
||||||
|
props.children = conv.app.children;
|
||||||
|
st.push(
|
||||||
|
Element::new("Search:Result")
|
||||||
|
.with_text("AirSync:Class", "Email")
|
||||||
|
.with_text("Search:LongId", sid)
|
||||||
|
.with_text("AirSync:CollectionId", cid)
|
||||||
|
.with(props),
|
||||||
|
);
|
||||||
|
ctx.items_out += 1;
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
st.push_text(
|
||||||
|
"Search:Range",
|
||||||
|
format!(
|
||||||
|
"{from}-{}",
|
||||||
|
(from + total.min(to + 1 - from)).saturating_sub(1)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
st.push_text("Search:Total", total.to_string());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
st.push_text("Search:Status", "3");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = imap::quote; // keep module linked for docs
|
||||||
|
Ok(Reply::Wbxml(
|
||||||
|
Element::new("Search:Search")
|
||||||
|
.with_text("Search:Status", "1")
|
||||||
|
.with(Element::new("Search:Response").with(st)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ ResolveRecipients
|
||||||
|
|
||||||
|
pub async fn resolve_recipients(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||||
|
let mut root = Element::new("ResolveRecipients:ResolveRecipients");
|
||||||
|
let Some(req) = req else {
|
||||||
|
return Ok(Reply::Wbxml(
|
||||||
|
root.with_text("ResolveRecipients:Status", "5"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let opts = req.child("ResolveRecipients:Options");
|
||||||
|
let max_amb = opts
|
||||||
|
.and_then(|o| o.child_u32("ResolveRecipients:MaxAmbiguousRecipients"))
|
||||||
|
.unwrap_or(19)
|
||||||
|
.clamp(1, 100) as usize;
|
||||||
|
let want_certs = opts
|
||||||
|
.and_then(|o| o.child_u32("ResolveRecipients:CertificateRetrieval"))
|
||||||
|
.unwrap_or(1)
|
||||||
|
> 1;
|
||||||
|
let want_avail = opts
|
||||||
|
.map(|o| o.has("ResolveRecipients:Availability"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
root.push_text("ResolveRecipients:Status", "1");
|
||||||
|
for to in req.children_named("ResolveRecipients:To") {
|
||||||
|
let q = to.text_value();
|
||||||
|
let people = find_people(ctx, &q, max_amb).await.unwrap_or_default();
|
||||||
|
let mut resp =
|
||||||
|
Element::new("ResolveRecipients:Response").with_text("ResolveRecipients:To", q.clone());
|
||||||
|
let mut recips: Vec<(u8, String, String)> = people
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|p| {
|
||||||
|
let e = p.summary.emails.first()?.clone();
|
||||||
|
let n = if p.summary.display_name.is_empty() {
|
||||||
|
e.clone()
|
||||||
|
} else {
|
||||||
|
p.summary.display_name.clone()
|
||||||
|
};
|
||||||
|
Some((if p.from_gal { 1 } else { 2 }, n, e))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if recips.is_empty() && q.contains('@') && !q.contains(' ') {
|
||||||
|
recips.push((1, q.clone(), q.clone()));
|
||||||
|
}
|
||||||
|
let status = match recips.len() {
|
||||||
|
0 => "4",
|
||||||
|
1 => "1",
|
||||||
|
_ => "2",
|
||||||
|
};
|
||||||
|
resp.push_text("ResolveRecipients:Status", status);
|
||||||
|
resp.push_text("ResolveRecipients:RecipientCount", recips.len().to_string());
|
||||||
|
for (t, n, e) in recips {
|
||||||
|
let mut r = Element::new("ResolveRecipients:Recipient")
|
||||||
|
.with_text("ResolveRecipients:Type", t.to_string())
|
||||||
|
.with_text("ResolveRecipients:DisplayName", n)
|
||||||
|
.with_text("ResolveRecipients:EmailAddress", e);
|
||||||
|
if want_avail {
|
||||||
|
r.push(
|
||||||
|
Element::new("ResolveRecipients:Availability")
|
||||||
|
.with_text("ResolveRecipients:Status", "163"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if want_certs {
|
||||||
|
r.push(
|
||||||
|
Element::new("ResolveRecipients:Certificates")
|
||||||
|
.with_text("ResolveRecipients:Status", "7")
|
||||||
|
.with_text("ResolveRecipients:CertificateCount", "0"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resp.push(r);
|
||||||
|
}
|
||||||
|
root.push(resp);
|
||||||
|
}
|
||||||
|
Ok(Reply::Wbxml(root))
|
||||||
|
}
|
||||||
@@ -0,0 +1,836 @@
|
|||||||
|
//! Exchange ActiveSync HTTP endpoint (`/Microsoft-Server-ActiveSync`).
|
||||||
|
|
||||||
|
pub mod convert;
|
||||||
|
pub mod folders;
|
||||||
|
pub mod itemops;
|
||||||
|
pub mod mailcmd;
|
||||||
|
pub mod misc;
|
||||||
|
pub mod ping;
|
||||||
|
pub mod provision;
|
||||||
|
pub mod state;
|
||||||
|
pub mod sync;
|
||||||
|
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{ConnectInfo, State};
|
||||||
|
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use base64::Engine;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::backend::{AuthResult, Session};
|
||||||
|
use crate::config::{LoginFormat, Settings};
|
||||||
|
use crate::db::{self, Device};
|
||||||
|
use crate::metrics::{ActiveRequest, Event};
|
||||||
|
use crate::util::{self, now};
|
||||||
|
use crate::wbxml::{self, Element};
|
||||||
|
|
||||||
|
pub const SUPPORTED_VERSIONS: &[&str] = &["2.5", "12.0", "12.1", "14.0", "14.1"];
|
||||||
|
pub const COMMANDS: &str = "Sync,SendMail,SmartForward,SmartReply,GetAttachment,GetHierarchy,CreateCollection,DeleteCollection,MoveCollection,FolderSync,FolderCreate,FolderDelete,FolderUpdate,MoveItems,GetItemEstimate,MeetingResponse,Search,Settings,Ping,ItemOperations,Provision,ResolveRecipients,ValidateCert";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct EasRequest {
|
||||||
|
pub cmd: String,
|
||||||
|
pub user: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub device_type: String,
|
||||||
|
pub version: String,
|
||||||
|
pub policy_key: Option<u32>,
|
||||||
|
pub attachment_name: Option<String>,
|
||||||
|
pub collection_id: Option<String>,
|
||||||
|
pub item_id: Option<String>,
|
||||||
|
pub long_id: Option<String>,
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
pub occurrence: Option<String>,
|
||||||
|
pub save_in_sent: bool,
|
||||||
|
pub accept_multipart: bool,
|
||||||
|
pub user_agent: String,
|
||||||
|
pub content_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EasRequest {
|
||||||
|
/// Protocol version as integer: 25, 120, 121, 140, 141.
|
||||||
|
pub fn ver(&self) -> u32 {
|
||||||
|
match self.version.as_str() {
|
||||||
|
"2.5" => 25,
|
||||||
|
"12.0" => 120,
|
||||||
|
"12.1" => 121,
|
||||||
|
"14.0" => 140,
|
||||||
|
"14.1" => 141,
|
||||||
|
"16.0" => 160,
|
||||||
|
"16.1" => 161,
|
||||||
|
_ => 25,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE64_COMMANDS: &[(u8, &str)] = &[
|
||||||
|
(0, "Sync"),
|
||||||
|
(1, "SendMail"),
|
||||||
|
(2, "SmartForward"),
|
||||||
|
(3, "SmartReply"),
|
||||||
|
(4, "GetAttachment"),
|
||||||
|
(9, "FolderSync"),
|
||||||
|
(10, "FolderCreate"),
|
||||||
|
(11, "FolderDelete"),
|
||||||
|
(12, "FolderUpdate"),
|
||||||
|
(13, "MoveItems"),
|
||||||
|
(14, "GetItemEstimate"),
|
||||||
|
(15, "MeetingResponse"),
|
||||||
|
(16, "Search"),
|
||||||
|
(17, "Settings"),
|
||||||
|
(18, "Ping"),
|
||||||
|
(19, "ItemOperations"),
|
||||||
|
(20, "Provision"),
|
||||||
|
(21, "ResolveRecipients"),
|
||||||
|
(22, "ValidateCert"),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn parse_request(uri: &Uri, headers: &HeaderMap) -> Result<EasRequest, &'static str> {
|
||||||
|
let q = uri.query().unwrap_or("");
|
||||||
|
let h = |n: &str| {
|
||||||
|
headers
|
||||||
|
.get(n)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
let mut r = EasRequest {
|
||||||
|
user_agent: h("user-agent"),
|
||||||
|
content_type: h("content-type"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if q.contains("Cmd=") || q.contains("DeviceId=") || q.is_empty() {
|
||||||
|
for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
|
||||||
|
let v = v.to_string();
|
||||||
|
match k.as_ref() {
|
||||||
|
"Cmd" => r.cmd = v,
|
||||||
|
"User" => r.user = v,
|
||||||
|
"DeviceId" => r.device_id = v,
|
||||||
|
"DeviceType" => r.device_type = v,
|
||||||
|
"AttachmentName" => r.attachment_name = Some(v),
|
||||||
|
"CollectionId" => r.collection_id = Some(v),
|
||||||
|
"ItemId" => r.item_id = Some(v),
|
||||||
|
"LongId" => r.long_id = Some(v),
|
||||||
|
"ParentId" => r.parent_id = Some(v),
|
||||||
|
"Occurrence" => r.occurrence = Some(v),
|
||||||
|
"SaveInSent" => r.save_in_sent = v.eq_ignore_ascii_case("T"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.version = h("ms-asprotocolversion");
|
||||||
|
let pk = h("x-ms-policykey");
|
||||||
|
if !pk.is_empty() {
|
||||||
|
r.policy_key = pk.parse().ok();
|
||||||
|
}
|
||||||
|
r.accept_multipart = h("ms-asacceptmultipart").eq_ignore_ascii_case("T");
|
||||||
|
} else {
|
||||||
|
// base64 encoded query (MS-ASHTTP 2.2.1.1.1.1)
|
||||||
|
let raw = percent_encoding::percent_decode_str(q)
|
||||||
|
.decode_utf8_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let b = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(raw.trim())
|
||||||
|
.map_err(|_| "invalid base64 query")?;
|
||||||
|
struct Cur<'a> {
|
||||||
|
b: &'a [u8],
|
||||||
|
p: usize,
|
||||||
|
}
|
||||||
|
impl<'a> Cur<'a> {
|
||||||
|
fn byte(&mut self) -> Result<u8, &'static str> {
|
||||||
|
let v = *self.b.get(self.p).ok_or("truncated query")?;
|
||||||
|
self.p += 1;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
fn take(&mut self, n: usize) -> Result<&'a [u8], &'static str> {
|
||||||
|
let s = self.b.get(self.p..self.p + n).ok_or("truncated query")?;
|
||||||
|
self.p += n;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut c = Cur { b: &b, p: 0 };
|
||||||
|
let ver = c.byte()?;
|
||||||
|
r.version = format!("{}.{}", ver / 10, ver % 10);
|
||||||
|
let code = c.byte()?;
|
||||||
|
r.cmd = BASE64_COMMANDS
|
||||||
|
.iter()
|
||||||
|
.find(|(cc, _)| *cc == code)
|
||||||
|
.map(|(_, n)| n.to_string())
|
||||||
|
.ok_or("unknown command code")?;
|
||||||
|
let _locale = c.take(2)?;
|
||||||
|
let dlen = c.byte()? as usize;
|
||||||
|
let dev = c.take(dlen)?;
|
||||||
|
r.device_id = if dev.iter().all(|x| x.is_ascii_graphic()) {
|
||||||
|
String::from_utf8_lossy(dev).into_owned()
|
||||||
|
} else {
|
||||||
|
hex::encode(dev)
|
||||||
|
};
|
||||||
|
let pklen = c.byte()? as usize;
|
||||||
|
let pk = c.take(pklen)?;
|
||||||
|
if pklen == 4 {
|
||||||
|
r.policy_key = Some(u32::from_le_bytes([pk[0], pk[1], pk[2], pk[3]]));
|
||||||
|
}
|
||||||
|
let tlen = c.byte()? as usize;
|
||||||
|
r.device_type = String::from_utf8_lossy(c.take(tlen)?).into_owned();
|
||||||
|
while c.p + 1 < c.b.len() {
|
||||||
|
let tag = c.byte()?;
|
||||||
|
let len = c.byte()? as usize;
|
||||||
|
let val = c.take(len)?;
|
||||||
|
let sv = String::from_utf8_lossy(val).into_owned();
|
||||||
|
match tag {
|
||||||
|
0 => r.attachment_name = Some(sv),
|
||||||
|
1 => r.collection_id = Some(sv),
|
||||||
|
3 => r.item_id = Some(sv),
|
||||||
|
4 => r.long_id = Some(sv),
|
||||||
|
5 => r.parent_id = Some(sv),
|
||||||
|
6 => r.occurrence = Some(sv),
|
||||||
|
7 => {
|
||||||
|
let o = val.first().copied().unwrap_or(0);
|
||||||
|
r.save_in_sent = o & 0x01 != 0;
|
||||||
|
r.accept_multipart = o & 0x02 != 0;
|
||||||
|
}
|
||||||
|
8 => r.user = sv,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.version.is_empty() {
|
||||||
|
r.version = "2.5".into();
|
||||||
|
}
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_device_id(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.len() <= 128
|
||||||
|
&& s.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_device_type(s: &str) -> bool {
|
||||||
|
s.len() <= 64 && s.chars().all(|c| c.is_ascii_graphic())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine the real client IP honouring trusted proxies.
|
||||||
|
pub fn client_ip(peer: SocketAddr, headers: &HeaderMap, s: &Settings) -> IpAddr {
|
||||||
|
let peer_ip = util::normalize(peer.ip());
|
||||||
|
if !util::ip_in_any(&peer_ip, &s.security.trusted_proxies) {
|
||||||
|
return peer_ip;
|
||||||
|
}
|
||||||
|
let hv = headers
|
||||||
|
.get(s.security.real_ip_header.as_str())
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
// walk right-to-left skipping trusted proxies
|
||||||
|
for part in hv.split(',').rev() {
|
||||||
|
if let Ok(ip) = part.trim().parse::<IpAddr>() {
|
||||||
|
let ip = util::normalize(ip);
|
||||||
|
if !util::ip_in_any(&ip, &s.security.trusted_proxies) {
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
peer_ip
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `Authorization: Basic` and normalise the login according to settings.
|
||||||
|
pub fn basic_auth(headers: &HeaderMap, s: &Settings) -> Option<(String, String)> {
|
||||||
|
let v = headers.get("authorization")?.to_str().ok()?;
|
||||||
|
let (scheme, data) = v.split_once(' ')?;
|
||||||
|
if !scheme.eq_ignore_ascii_case("basic") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dec = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(data.trim())
|
||||||
|
.ok()?;
|
||||||
|
let dec = String::from_utf8(dec).ok()?;
|
||||||
|
let (user, pass) = dec.split_once(':')?;
|
||||||
|
let login = normalize_login(user, s)?;
|
||||||
|
if pass.is_empty() || pass.len() > 1024 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((login, pass.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_login(user: &str, s: &Settings) -> Option<String> {
|
||||||
|
let mut u = user.trim().to_string();
|
||||||
|
if let Some((_, rest)) = u.split_once('\\') {
|
||||||
|
u = rest.to_string();
|
||||||
|
}
|
||||||
|
let u = match s.general.login_format {
|
||||||
|
LoginFormat::AsGiven => u,
|
||||||
|
LoginFormat::LocalPart => u.split('@').next().unwrap_or("").to_string(),
|
||||||
|
LoginFormat::FullEmail => {
|
||||||
|
if u.contains('@') || s.general.default_domain.is_empty() {
|
||||||
|
u
|
||||||
|
} else {
|
||||||
|
format!("{u}@{}", s.general.default_domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let u = u.to_lowercase();
|
||||||
|
util::valid_login(&u).then_some(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access policy checks shared by EAS and Autodiscover.
|
||||||
|
pub fn user_allowed(login: &str, s: &Settings) -> bool {
|
||||||
|
let sec = &s.security;
|
||||||
|
if sec
|
||||||
|
.blocked_users
|
||||||
|
.iter()
|
||||||
|
.any(|b| b.eq_ignore_ascii_case(login))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !sec.allowed_users.is_empty()
|
||||||
|
&& !sec
|
||||||
|
.allowed_users
|
||||||
|
.iter()
|
||||||
|
.any(|b| b.eq_ignore_ascii_case(login))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !s.general.allowed_domains.is_empty() {
|
||||||
|
let dom = login.split('@').nth(1).unwrap_or(&s.general.default_domain);
|
||||||
|
if !s
|
||||||
|
.general
|
||||||
|
.allowed_domains
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.eq_ignore_ascii_case(dom))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Reply {
|
||||||
|
Wbxml(Element),
|
||||||
|
Raw(&'static str, Vec<u8>),
|
||||||
|
Empty,
|
||||||
|
Status(StatusCode),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Ctx {
|
||||||
|
pub app: Arc<App>,
|
||||||
|
pub s: Arc<Settings>,
|
||||||
|
pub req: EasRequest,
|
||||||
|
pub dev: Device,
|
||||||
|
pub sess: Session,
|
||||||
|
pub ip: String,
|
||||||
|
pub items_out: u64,
|
||||||
|
pub items_in: u64,
|
||||||
|
pub status: String,
|
||||||
|
pub debug: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ctx {
|
||||||
|
pub fn ping_key(&self) -> String {
|
||||||
|
format!("{}\u{0}{}", self.req.device_id, self.sess.login)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user(&self) -> &str {
|
||||||
|
&self.sess.login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unauthorized() -> Response {
|
||||||
|
let mut r = (StatusCode::UNAUTHORIZED, "Access denied").into_response();
|
||||||
|
r.headers_mut().insert(
|
||||||
|
"WWW-Authenticate",
|
||||||
|
HeaderValue::from_static("Basic realm=\"ActiveSync\""),
|
||||||
|
);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_headers(r: &mut Response, s: &Settings) {
|
||||||
|
let h = r.headers_mut();
|
||||||
|
h.insert("MS-Server-ActiveSync", HeaderValue::from_static("14.1"));
|
||||||
|
h.insert("Cache-Control", HeaderValue::from_static("private"));
|
||||||
|
let _ = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn versions(s: &Settings) -> String {
|
||||||
|
let max = s.general.max_protocol.as_str();
|
||||||
|
let idx = SUPPORTED_VERSIONS
|
||||||
|
.iter()
|
||||||
|
.position(|v| *v == max)
|
||||||
|
.unwrap_or(SUPPORTED_VERSIONS.len() - 1);
|
||||||
|
SUPPORTED_VERSIONS[..=idx].join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axum handler for the ActiveSync endpoint.
|
||||||
|
pub async fn handler(
|
||||||
|
State(app): State<Arc<App>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
method: Method,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Body,
|
||||||
|
) -> Response {
|
||||||
|
let s = app.settings();
|
||||||
|
let ip = client_ip(peer, &headers, &s);
|
||||||
|
let ip_s = ip.to_string();
|
||||||
|
if !s.security.eas_allowed_networks.is_empty()
|
||||||
|
&& !util::ip_in_any(&ip, &s.security.eas_allowed_networks)
|
||||||
|
{
|
||||||
|
tracing::warn!(ip = %ip_s, "ActiveSync request from network that is not allowed");
|
||||||
|
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
|
||||||
|
}
|
||||||
|
if method == Method::GET {
|
||||||
|
let mut r = (
|
||||||
|
StatusCode::OK,
|
||||||
|
[("Content-Type", "text/html; charset=utf-8")],
|
||||||
|
"<!doctype html><title>ActiveSync</title><p>This is an Exchange ActiveSync endpoint and can only be accessed by ActiveSync-capable devices.</p>",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
server_headers(&mut r, &s);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
if method != Method::POST && method != Method::OPTIONS {
|
||||||
|
return StatusCode::METHOD_NOT_ALLOWED.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- authentication
|
||||||
|
let Some((login, password)) = basic_auth(&headers, &s) else {
|
||||||
|
return unauthorized();
|
||||||
|
};
|
||||||
|
let ip_key = format!("ip:{ip_s}");
|
||||||
|
let user_key = format!("user:{login}");
|
||||||
|
for k in [&ip_key, &user_key] {
|
||||||
|
if let Some(until) = app.auth.locked_until(k) {
|
||||||
|
app.metrics.auth_failure();
|
||||||
|
let mut r = (StatusCode::TOO_MANY_REQUESTS, "Too many failed logins").into_response();
|
||||||
|
if let Ok(v) = HeaderValue::from_str(&(until - now()).max(1).to_string()) {
|
||||||
|
r.headers_mut().insert("Retry-After", v);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !user_allowed(&login, &s) {
|
||||||
|
tracing::warn!(user = %login, ip = %ip_s, "ActiveSync access denied by policy");
|
||||||
|
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
|
||||||
|
}
|
||||||
|
let mut sess = app.session(&login, &password);
|
||||||
|
let cache_key = app.pool.key(&login, &password);
|
||||||
|
if !app.auth.is_cached(&cache_key) {
|
||||||
|
match sess.authenticate().await {
|
||||||
|
Ok(AuthResult::Ok) => {
|
||||||
|
app.auth.success(&user_key);
|
||||||
|
app.auth.cache_ok(cache_key, s.security.auth_cache_secs);
|
||||||
|
}
|
||||||
|
Ok(AuthResult::Denied) => {
|
||||||
|
app.metrics.auth_failure();
|
||||||
|
if s.security.log_auth_failures {
|
||||||
|
tracing::warn!(target: "active_push::auth", "authentication failed user={login} ip={ip_s} service=activesync");
|
||||||
|
}
|
||||||
|
let max = s.security.auth_fail_max;
|
||||||
|
let w = s.security.auth_fail_window_secs;
|
||||||
|
let l = s.security.lockout_secs;
|
||||||
|
if app.auth.fail(&ip_key, max * 3, w, l) || app.auth.fail(&user_key, max, w, l) {
|
||||||
|
tracing::warn!(user = %login, ip = %ip_s, "login locked out after repeated failures");
|
||||||
|
db::audit(
|
||||||
|
&app.db,
|
||||||
|
"system",
|
||||||
|
"lockout",
|
||||||
|
&login,
|
||||||
|
&ip_s,
|
||||||
|
"repeated ActiveSync login failures",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(user = %login, "backend unavailable during authentication: {e:#}");
|
||||||
|
let mut r =
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "Backend unavailable").into_response();
|
||||||
|
if let Ok(v) = HeaderValue::from_str(&s.sync.retry_after_secs.to_string()) {
|
||||||
|
r.headers_mut().insert("Retry-After", v);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == Method::OPTIONS {
|
||||||
|
let mut r = StatusCode::OK.into_response();
|
||||||
|
server_headers(&mut r, &s);
|
||||||
|
let h = r.headers_mut();
|
||||||
|
h.insert(
|
||||||
|
"MS-ASProtocolVersions",
|
||||||
|
HeaderValue::from_str(&versions(&s)).unwrap(),
|
||||||
|
);
|
||||||
|
h.insert("MS-ASProtocolCommands", HeaderValue::from_static(COMMANDS));
|
||||||
|
h.insert("Allow", HeaderValue::from_static("OPTIONS,POST"));
|
||||||
|
h.insert("Public", HeaderValue::from_static("OPTIONS,POST"));
|
||||||
|
sess.close().await;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- request
|
||||||
|
let req = match parse_request(&uri, &headers) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::info!(ip = %ip_s, "bad ActiveSync request: {e}");
|
||||||
|
return (StatusCode::BAD_REQUEST, e).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut req = req;
|
||||||
|
if !COMMANDS.split(',').any(|c| c == req.cmd) {
|
||||||
|
req.cmd = "Unknown".into();
|
||||||
|
}
|
||||||
|
if !valid_device_id(&req.device_id)
|
||||||
|
|| !valid_device_type(&req.device_type)
|
||||||
|
|| req.cmd.is_empty()
|
||||||
|
{
|
||||||
|
return (StatusCode::BAD_REQUEST, "Missing or invalid parameters").into_response();
|
||||||
|
}
|
||||||
|
let blocked_type = s.security.blocked_device_types.iter().any(|b| {
|
||||||
|
let b = b.to_lowercase();
|
||||||
|
!b.is_empty()
|
||||||
|
&& (req.device_type.to_lowercase().contains(&b)
|
||||||
|
|| req.user_agent.to_lowercase().contains(&b))
|
||||||
|
});
|
||||||
|
if blocked_type {
|
||||||
|
tracing::warn!(user = %login, device = %req.device_id, "device type blocked by policy");
|
||||||
|
return (StatusCode::FORBIDDEN, "Device type not allowed").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_body = (s.sync.max_request_mb * 1024 * 1024) as usize;
|
||||||
|
let body = match http_body_util::Limited::new(body, max_body).collect().await {
|
||||||
|
Ok(b) => b.to_bytes(),
|
||||||
|
Err(_) => return (StatusCode::PAYLOAD_TOO_LARGE, "Request too large").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (dev, created) = match db::device_get_or_create(
|
||||||
|
&app.db,
|
||||||
|
&req.device_id,
|
||||||
|
&login,
|
||||||
|
&req.device_type,
|
||||||
|
&req.user_agent,
|
||||||
|
!s.security.require_device_approval,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("database error: {e:#}");
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if created {
|
||||||
|
tracing::info!(user = %login, device = %req.device_id, kind = %req.device_type, "new device registered");
|
||||||
|
db::audit(
|
||||||
|
&app.db,
|
||||||
|
&login,
|
||||||
|
"device_new",
|
||||||
|
&req.device_id,
|
||||||
|
&ip_s,
|
||||||
|
&req.user_agent,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if dev.blocked != 0 {
|
||||||
|
return (StatusCode::FORBIDDEN, "Device blocked").into_response();
|
||||||
|
}
|
||||||
|
if dev.approved == 0 {
|
||||||
|
tracing::info!(user = %login, device = %req.device_id, "device awaiting approval");
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Device awaiting administrator approval",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
let _ = db::device_touch(
|
||||||
|
&app.db,
|
||||||
|
dev.id,
|
||||||
|
&ip_s,
|
||||||
|
&req.cmd,
|
||||||
|
&req.version,
|
||||||
|
&req.user_agent,
|
||||||
|
&req.device_type,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let debug = s.logging.log_wbxml
|
||||||
|
|| s.logging
|
||||||
|
.debug_users
|
||||||
|
.iter()
|
||||||
|
.any(|u| u.eq_ignore_ascii_case(&login));
|
||||||
|
let started = Instant::now();
|
||||||
|
let active_id = app.metrics.begin(ActiveRequest {
|
||||||
|
id: 0,
|
||||||
|
user: login.clone(),
|
||||||
|
device_id: req.device_id.clone(),
|
||||||
|
device_type: req.device_type.clone(),
|
||||||
|
command: req.cmd.clone(),
|
||||||
|
ip: ip_s.clone(),
|
||||||
|
started: now(),
|
||||||
|
version: req.version.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut ctx = Ctx {
|
||||||
|
app: app.clone(),
|
||||||
|
s: s.clone(),
|
||||||
|
req: req.clone(),
|
||||||
|
dev,
|
||||||
|
sess,
|
||||||
|
ip: ip_s.clone(),
|
||||||
|
items_out: 0,
|
||||||
|
items_in: 0,
|
||||||
|
status: String::new(),
|
||||||
|
debug,
|
||||||
|
};
|
||||||
|
// A new request from the device ends its hanging Ping/Sync.
|
||||||
|
if ctx.req.cmd != "Ping" && ctx.req.cmd != "Sync" {
|
||||||
|
app.metrics.ping_interrupt(&ctx.ping_key());
|
||||||
|
}
|
||||||
|
|
||||||
|
let reply = dispatch(&mut ctx, &body).await;
|
||||||
|
let (mut resp, http, out_len) = match reply {
|
||||||
|
Ok(Reply::Wbxml(el)) => {
|
||||||
|
if ctx.debug {
|
||||||
|
tracing::info!(user = %login, cmd = %req.cmd, "response:\n{}", el.to_xml());
|
||||||
|
}
|
||||||
|
let bytes = wbxml::encode(&el);
|
||||||
|
let n = bytes.len();
|
||||||
|
let mut r = Response::new(Body::from(bytes));
|
||||||
|
r.headers_mut().insert(
|
||||||
|
"Content-Type",
|
||||||
|
HeaderValue::from_static("application/vnd.ms-sync.wbxml"),
|
||||||
|
);
|
||||||
|
(r, 200u16, n)
|
||||||
|
}
|
||||||
|
Ok(Reply::Raw(ct, bytes)) => {
|
||||||
|
let n = bytes.len();
|
||||||
|
let mut r = Response::new(Body::from(bytes));
|
||||||
|
r.headers_mut()
|
||||||
|
.insert("Content-Type", HeaderValue::from_static(ct));
|
||||||
|
(r, 200, n)
|
||||||
|
}
|
||||||
|
Ok(Reply::Empty) => (StatusCode::OK.into_response(), 200, 0),
|
||||||
|
Ok(Reply::Status(code)) => (code.into_response(), code.as_u16(), 0),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(user = %login, device = %req.device_id, cmd = %req.cmd, "request failed: {e:#}");
|
||||||
|
ctx.status = format!("error: {e}");
|
||||||
|
let mut r = StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
if e.to_string().contains("IMAP") || e.to_string().contains("connect") {
|
||||||
|
r = StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||||
|
if let Ok(v) = HeaderValue::from_str(&s.sync.retry_after_secs.to_string()) {
|
||||||
|
r.headers_mut().insert("Retry-After", v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let code = r.status().as_u16();
|
||||||
|
(r, code, 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
server_headers(&mut resp, &s);
|
||||||
|
let ms = started.elapsed().as_millis() as u64;
|
||||||
|
if ms > 5000 && req.cmd != "Ping" && req.cmd != "Sync" {
|
||||||
|
tracing::info!(user = %login, cmd = %req.cmd, ms, "slow request");
|
||||||
|
}
|
||||||
|
app.metrics.end(
|
||||||
|
active_id,
|
||||||
|
Event {
|
||||||
|
ts: now(),
|
||||||
|
user: login.clone(),
|
||||||
|
device_id: req.device_id.clone(),
|
||||||
|
device_type: req.device_type.clone(),
|
||||||
|
command: req.cmd.clone(),
|
||||||
|
ip: ip_s,
|
||||||
|
http,
|
||||||
|
status: if ctx.status.is_empty() {
|
||||||
|
"ok".into()
|
||||||
|
} else {
|
||||||
|
ctx.status.clone()
|
||||||
|
},
|
||||||
|
ms,
|
||||||
|
bytes_in: body.len() as u64,
|
||||||
|
bytes_out: out_len as u64,
|
||||||
|
items_out: ctx.items_out,
|
||||||
|
items_in: ctx.items_in,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ctx.sess.close().await;
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
fn needs_provisioning(cmd: &str) -> bool {
|
||||||
|
!matches!(cmd, "Provision" | "Ping")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WBXML root/status for "device must provision" answers (AS 14+).
|
||||||
|
fn provision_status_reply(cmd: &str, status: u32) -> Option<Element> {
|
||||||
|
let (root, st) = match cmd {
|
||||||
|
"Sync" => ("AirSync:Sync", "AirSync:Status"),
|
||||||
|
"FolderSync" => ("FolderHierarchy:FolderSync", "FolderHierarchy:Status"),
|
||||||
|
"FolderCreate" => ("FolderHierarchy:FolderCreate", "FolderHierarchy:Status"),
|
||||||
|
"FolderUpdate" => ("FolderHierarchy:FolderUpdate", "FolderHierarchy:Status"),
|
||||||
|
"FolderDelete" => ("FolderHierarchy:FolderDelete", "FolderHierarchy:Status"),
|
||||||
|
"GetItemEstimate" => ("GetItemEstimate:GetItemEstimate", "GetItemEstimate:Status"),
|
||||||
|
"Ping" => ("Ping:Ping", "Ping:Status"),
|
||||||
|
"ItemOperations" => ("ItemOperations:ItemOperations", "ItemOperations:Status"),
|
||||||
|
"Settings" => ("Settings:Settings", "Settings:Status"),
|
||||||
|
"Search" => ("Search:Search", "Search:Status"),
|
||||||
|
"SendMail" => ("ComposeMail:SendMail", "ComposeMail:Status"),
|
||||||
|
"SmartReply" => ("ComposeMail:SmartReply", "ComposeMail:Status"),
|
||||||
|
"SmartForward" => ("ComposeMail:SmartForward", "ComposeMail:Status"),
|
||||||
|
"MeetingResponse" => ("MeetingResponse:MeetingResponse", "MeetingResponse:Status"),
|
||||||
|
"ResolveRecipients" => (
|
||||||
|
"ResolveRecipients:ResolveRecipients",
|
||||||
|
"ResolveRecipients:Status",
|
||||||
|
),
|
||||||
|
"MoveItems" => ("Move:MoveItems", "Move:Status"),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(Element::new(root).with_text(st, status.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch(ctx: &mut Ctx, body: &[u8]) -> anyhow::Result<Reply> {
|
||||||
|
// ---- provisioning enforcement
|
||||||
|
let wipe_pending = matches!(
|
||||||
|
ctx.dev.wipe_status,
|
||||||
|
db::WIPE_PENDING
|
||||||
|
| db::WIPE_REQUESTED
|
||||||
|
| db::WIPE_ACCOUNT_PENDING
|
||||||
|
| db::WIPE_ACCOUNT_REQUESTED
|
||||||
|
);
|
||||||
|
if ctx.s.provisioning.enabled
|
||||||
|
&& (needs_provisioning(&ctx.req.cmd) || (wipe_pending && ctx.req.cmd != "Provision"))
|
||||||
|
{
|
||||||
|
let sent = ctx.req.policy_key;
|
||||||
|
let current = ctx.dev.policy_key as u32;
|
||||||
|
let policy_changed = !provision::policy_hash_matches(ctx);
|
||||||
|
let ok = match sent {
|
||||||
|
Some(k) => k != 0 && k == current && !policy_changed && !wipe_pending,
|
||||||
|
None => ctx.s.provisioning.loose && !wipe_pending,
|
||||||
|
};
|
||||||
|
if !ok {
|
||||||
|
ctx.status = "provisioning required".into();
|
||||||
|
tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, "provisioning required");
|
||||||
|
if ctx.req.ver() >= 140 {
|
||||||
|
let code = if wipe_pending {
|
||||||
|
140
|
||||||
|
} else if sent.map(|k| k != 0).unwrap_or(false) {
|
||||||
|
144
|
||||||
|
} else {
|
||||||
|
142
|
||||||
|
};
|
||||||
|
if let Some(e) = provision_status_reply(&ctx.req.cmd, code) {
|
||||||
|
return Ok(Reply::Wbxml(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(Reply::Status(StatusCode::from_u16(449).unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- plain-input commands (AS < 14 SendMail family uses raw MIME)
|
||||||
|
let is_mime = ctx
|
||||||
|
.req
|
||||||
|
.content_type
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.starts_with("message/rfc822");
|
||||||
|
let req_el = if body.is_empty() || is_mime || ctx.req.cmd == "GetAttachment" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match wbxml::decode(body) {
|
||||||
|
Ok(e) => {
|
||||||
|
if ctx.debug {
|
||||||
|
tracing::info!(user = %ctx.user(), cmd = %ctx.req.cmd, "request:\n{}", e.to_xml());
|
||||||
|
}
|
||||||
|
Some(e)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::info!(user = %ctx.user(), cmd = %ctx.req.cmd, "invalid WBXML: {err}");
|
||||||
|
ctx.status = "invalid wbxml".into();
|
||||||
|
return Ok(Reply::Status(StatusCode::BAD_REQUEST));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match ctx.req.cmd.as_str() {
|
||||||
|
"Provision" => provision::provision(ctx, req_el).await,
|
||||||
|
"FolderSync" => folders::folder_sync(ctx, req_el).await,
|
||||||
|
"FolderCreate" | "FolderUpdate" | "FolderDelete" => {
|
||||||
|
folders::folder_change(ctx, req_el).await
|
||||||
|
}
|
||||||
|
"GetHierarchy" => folders::get_hierarchy(ctx).await,
|
||||||
|
"Sync" => sync::sync(ctx, req_el).await,
|
||||||
|
"GetItemEstimate" => sync::get_item_estimate(ctx, req_el).await,
|
||||||
|
"Ping" => ping::ping(ctx, req_el).await,
|
||||||
|
"MoveItems" => sync::move_items(ctx, req_el).await,
|
||||||
|
"SendMail" | "SmartReply" | "SmartForward" => mailcmd::send_mail(ctx, req_el, body).await,
|
||||||
|
"GetAttachment" => itemops::get_attachment(ctx).await,
|
||||||
|
"ItemOperations" => itemops::item_operations(ctx, req_el).await,
|
||||||
|
"Settings" => misc::settings(ctx, req_el).await,
|
||||||
|
"Search" => misc::search(ctx, req_el).await,
|
||||||
|
"ResolveRecipients" => misc::resolve_recipients(ctx, req_el).await,
|
||||||
|
"MeetingResponse" => mailcmd::meeting_response(ctx, req_el).await,
|
||||||
|
"ValidateCert" => Ok(Reply::Wbxml(
|
||||||
|
Element::new("ValidateCert:ValidateCert").with_text("ValidateCert:Status", "1"),
|
||||||
|
)),
|
||||||
|
other => {
|
||||||
|
tracing::info!(cmd = other, "unsupported ActiveSync command");
|
||||||
|
ctx.status = "not implemented".into();
|
||||||
|
Ok(Reply::Status(StatusCode::NOT_IMPLEMENTED))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base64_query() {
|
||||||
|
// version 14.1, Sync (0), locale 0x0409, devid "abc", pk 4 bytes, type "iPhone", User "jan"
|
||||||
|
let mut b = vec![141u8, 0, 0x09, 0x04, 3];
|
||||||
|
b.extend_from_slice(b"abc");
|
||||||
|
b.push(4);
|
||||||
|
b.extend_from_slice(&7u32.to_le_bytes());
|
||||||
|
b.push(6);
|
||||||
|
b.extend_from_slice(b"iPhone");
|
||||||
|
b.extend_from_slice(&[8, 3]);
|
||||||
|
b.extend_from_slice(b"jan");
|
||||||
|
let q = base64::engine::general_purpose::STANDARD.encode(&b);
|
||||||
|
let uri: Uri = format!("/Microsoft-Server-ActiveSync?{q}").parse().unwrap();
|
||||||
|
let r = parse_request(&uri, &HeaderMap::new()).unwrap();
|
||||||
|
assert_eq!(r.cmd, "Sync");
|
||||||
|
assert_eq!(r.version, "14.1");
|
||||||
|
assert_eq!(r.device_id, "abc");
|
||||||
|
assert_eq!(r.policy_key, Some(7));
|
||||||
|
assert_eq!(r.device_type, "iPhone");
|
||||||
|
assert_eq!(r.user, "jan");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_query() {
|
||||||
|
let uri: Uri = "/Microsoft-Server-ActiveSync?Cmd=FolderSync&User=jan&DeviceId=ABC123&DeviceType=Android"
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert("MS-ASProtocolVersion", HeaderValue::from_static("14.0"));
|
||||||
|
h.insert("X-MS-PolicyKey", HeaderValue::from_static("123"));
|
||||||
|
let r = parse_request(&uri, &h).unwrap();
|
||||||
|
assert_eq!(r.cmd, "FolderSync");
|
||||||
|
assert_eq!(r.ver(), 140);
|
||||||
|
assert_eq!(r.policy_key, Some(123));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_formats() {
|
||||||
|
let mut s = Settings::default();
|
||||||
|
s.general.default_domain = "firma.pl".into();
|
||||||
|
s.general.login_format = LoginFormat::FullEmail;
|
||||||
|
assert_eq!(normalize_login("DOM\\Jan", &s).unwrap(), "jan@firma.pl");
|
||||||
|
s.general.login_format = LoginFormat::LocalPart;
|
||||||
|
assert_eq!(normalize_login("jan@firma.pl", &s).unwrap(), "jan");
|
||||||
|
assert!(normalize_login("a b", &s).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//! Ping (push) and the shared "wait for changes" loop used by hanging Sync.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::folders::resolve;
|
||||||
|
use super::state::{self, CollectionState};
|
||||||
|
use super::sync::{diff, effective_filter, filter_since};
|
||||||
|
use super::{Ctx, Reply};
|
||||||
|
use crate::backend::{BFolder, Kind};
|
||||||
|
use crate::db;
|
||||||
|
use crate::metrics::PingInfo;
|
||||||
|
use crate::util::now;
|
||||||
|
use crate::wbxml::Element;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct PingFolder {
|
||||||
|
id: String,
|
||||||
|
class: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ping_status(status: u32) -> Element {
|
||||||
|
Element::new("Ping:Ping").with_text("Ping:Status", status.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ping(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||||
|
let s = ctx.s.clone();
|
||||||
|
let mut lifetime = ctx.dev.ping_lifetime.max(0) as u64;
|
||||||
|
let mut folders: Vec<PingFolder> =
|
||||||
|
serde_json::from_str(&ctx.dev.ping_folders).unwrap_or_default();
|
||||||
|
if let Some(r) = &req {
|
||||||
|
if let Some(h) = r.child_u32("Ping:HeartbeatInterval") {
|
||||||
|
lifetime = h as u64;
|
||||||
|
}
|
||||||
|
if let Some(f) = r.child("Ping:Folders") {
|
||||||
|
folders = f
|
||||||
|
.children_named("Ping:Folder")
|
||||||
|
.map(|x| PingFolder {
|
||||||
|
id: x.child_text("Ping:Id").unwrap_or_default(),
|
||||||
|
class: x.child_text("Ping:Class").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.filter(|f| !f.id.is_empty())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lifetime == 0 || folders.is_empty() {
|
||||||
|
ctx.status = "ping: missing parameters".into();
|
||||||
|
return Ok(Reply::Wbxml(ping_status(3)));
|
||||||
|
}
|
||||||
|
if lifetime < s.ping.lifetime_min || lifetime > s.ping.lifetime_max {
|
||||||
|
let lim = if lifetime < s.ping.lifetime_min {
|
||||||
|
s.ping.lifetime_min
|
||||||
|
} else {
|
||||||
|
s.ping.lifetime_max
|
||||||
|
};
|
||||||
|
return Ok(Reply::Wbxml(
|
||||||
|
ping_status(5).with_text("Ping:HeartbeatInterval", lim.to_string()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if folders.len() > s.ping.max_folders {
|
||||||
|
return Ok(Reply::Wbxml(
|
||||||
|
ping_status(6).with_text("Ping:MaxFolders", s.ping.max_folders.to_string()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// persist for subsequent empty Pings
|
||||||
|
let fj = serde_json::to_string(&folders)?;
|
||||||
|
if fj != ctx.dev.ping_folders {
|
||||||
|
db::device_set_field(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.dev.id,
|
||||||
|
db::DeviceField::PingFolders,
|
||||||
|
None,
|
||||||
|
Some(&fj),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if lifetime as i64 != ctx.dev.ping_lifetime {
|
||||||
|
db::device_set_field(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.dev.id,
|
||||||
|
db::DeviceField::PingLifetime,
|
||||||
|
Some(lifetime as i64),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let ids: Vec<String> = folders.iter().map(|f| f.id.clone()).collect();
|
||||||
|
match wait_for_changes(ctx, &ids, lifetime, "ping").await {
|
||||||
|
Err(e) if e.to_string() == "hierarchy" => {
|
||||||
|
ctx.status = "ping: folder sync required".into();
|
||||||
|
Ok(Reply::Wbxml(ping_status(7)))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
Ok(None) => {
|
||||||
|
ctx.status = "ping: no changes".into();
|
||||||
|
Ok(Reply::Wbxml(ping_status(1)))
|
||||||
|
}
|
||||||
|
Ok(Some(changed)) => {
|
||||||
|
ctx.status = format!("ping: {} changed", changed.len());
|
||||||
|
let mut f = Element::new("Ping:Folders");
|
||||||
|
for c in changed {
|
||||||
|
f.push_text("Ping:Folder", c);
|
||||||
|
}
|
||||||
|
Ok(Reply::Wbxml(ping_status(2).with(f)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Watched {
|
||||||
|
id: String,
|
||||||
|
folder: BFolder,
|
||||||
|
sig: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the device's latest state for `cid` differ from the backend?
|
||||||
|
async fn has_changes(ctx: &mut Ctx, cid: &str, folder: &BFolder) -> Result<bool> {
|
||||||
|
let Some((_, _, st)) = state::load_latest::<CollectionState>(ctx, cid).await? else {
|
||||||
|
// never synced: nothing to report until the device primes the folder
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let filter = effective_filter(ctx, folder.kind(), st.filter);
|
||||||
|
let listing = match folder.kind() {
|
||||||
|
Kind::Tasks if filter == 8 => ctx.sess.list(folder, None).await?,
|
||||||
|
_ => ctx.sess.list(folder, filter_since(filter)).await?,
|
||||||
|
};
|
||||||
|
if !st.meta.is_empty() && !listing.meta.is_empty() && st.meta != listing.meta {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let d = diff(&st.items, &listing, folder.kind());
|
||||||
|
Ok(!(d.adds.is_empty() && d.changes.is_empty() && d.deletes.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until one of the collections changes. Returns Ok(None) on timeout/interruption,
|
||||||
|
/// Err("hierarchy") if a collection does not exist anymore.
|
||||||
|
pub async fn wait_for_changes(
|
||||||
|
ctx: &mut Ctx,
|
||||||
|
ids: &[String],
|
||||||
|
lifetime: u64,
|
||||||
|
kind: &'static str,
|
||||||
|
) -> Result<Option<Vec<String>>> {
|
||||||
|
let mut watched = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
match resolve(ctx, id).await? {
|
||||||
|
Some(f) => watched.push(Watched {
|
||||||
|
id: id.clone(),
|
||||||
|
folder: f,
|
||||||
|
sig: String::new(),
|
||||||
|
}),
|
||||||
|
None => {
|
||||||
|
ctx.app.stale_add(ctx.dev.id, id);
|
||||||
|
if kind == "ping" {
|
||||||
|
return Err(anyhow::anyhow!("hierarchy"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let key = ctx.ping_key();
|
||||||
|
let (cancel, gen) = ctx.app.metrics.ping_start(
|
||||||
|
&key,
|
||||||
|
PingInfo {
|
||||||
|
user: ctx.user().to_string(),
|
||||||
|
device_id: ctx.req.device_id.clone(),
|
||||||
|
device_type: ctx.req.device_type.clone(),
|
||||||
|
started: now(),
|
||||||
|
lifetime,
|
||||||
|
folders: watched.len(),
|
||||||
|
kind,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let result = async {
|
||||||
|
// initial check: changes that happened while the device was not pinging
|
||||||
|
let mut changed = Vec::new();
|
||||||
|
for w in watched.iter_mut() {
|
||||||
|
w.sig = ctx.sess.signature(&w.folder).await.unwrap_or_default();
|
||||||
|
let st = state::load_latest::<CollectionState>(ctx, &w.id).await?;
|
||||||
|
let stored = st.map(|(_, _, s)| s.signature).unwrap_or_default();
|
||||||
|
if (stored.is_empty() || stored != w.sig)
|
||||||
|
&& has_changes(ctx, &w.id.clone(), &w.folder.clone()).await?
|
||||||
|
{
|
||||||
|
changed.push(w.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed.is_empty() {
|
||||||
|
return Ok(Some(changed));
|
||||||
|
}
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(lifetime.saturating_sub(5).max(5));
|
||||||
|
let interval = Duration::from_secs(ctx.s.ping.check_interval_secs.max(5));
|
||||||
|
let mut checks = 0u32;
|
||||||
|
loop {
|
||||||
|
let now_i = Instant::now();
|
||||||
|
if now_i >= deadline {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let sleep = interval.min(deadline - now_i);
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(sleep) => {}
|
||||||
|
_ = cancel.notified() => return Ok(None),
|
||||||
|
_ = ctx.app.shutdown.cancelled() => return Ok(None),
|
||||||
|
}
|
||||||
|
checks += 1;
|
||||||
|
let mut changed = Vec::new();
|
||||||
|
for w in watched.iter_mut() {
|
||||||
|
let sig = match ctx.sess.signature(&w.folder).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("ping signature failed: {e:#}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if sig != w.sig {
|
||||||
|
w.sig = sig;
|
||||||
|
if has_changes(ctx, &w.id.clone(), &w.folder.clone()).await? {
|
||||||
|
changed.push(w.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed.is_empty() {
|
||||||
|
return Ok(Some(changed));
|
||||||
|
}
|
||||||
|
// every ~10 checks verify the folders still exist
|
||||||
|
if checks % 10 == 0 && kind == "ping" {
|
||||||
|
ctx.sess.invalidate_folders();
|
||||||
|
let all = ctx.sess.folders().await?;
|
||||||
|
if watched
|
||||||
|
.iter()
|
||||||
|
.any(|w| !all.iter().any(|f| f.id == w.folder.id))
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!("hierarchy"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
ctx.app.metrics.ping_end(&key, gen);
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
//! Provision command (policies + remote wipe).
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
use super::{Ctx, Reply};
|
||||||
|
use crate::config::Policy;
|
||||||
|
use crate::db::{self, DeviceField, DeviceInfo};
|
||||||
|
use crate::util::sha256_hex;
|
||||||
|
use crate::wbxml::Element;
|
||||||
|
|
||||||
|
pub fn policy_hash(p: &Policy) -> String {
|
||||||
|
sha256_hex(serde_json::to_string(p).unwrap_or_default().as_bytes())[..16].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn policy_hash_matches(ctx: &Ctx) -> bool {
|
||||||
|
let p = ctx.s.policy_for(ctx.user());
|
||||||
|
ctx.dev.policy_hash.is_empty() || ctx.dev.policy_hash == policy_hash(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn b(v: bool) -> &'static str {
|
||||||
|
if v {
|
||||||
|
"1"
|
||||||
|
} else {
|
||||||
|
"0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MS-ASPROV EASProvisionDoc.
|
||||||
|
pub fn provision_doc(p: &Policy, ver: u32) -> Element {
|
||||||
|
let mut d = Element::new("Provision:EASProvisionDoc");
|
||||||
|
d.push_text("Provision:DevicePasswordEnabled", b(p.devpwenabled));
|
||||||
|
d.push_text(
|
||||||
|
"Provision:AlphanumericDevicePasswordRequired",
|
||||||
|
b(p.alphanumpwreq),
|
||||||
|
);
|
||||||
|
d.push_text("Provision:PasswordRecoveryEnabled", b(p.pwrecoveryenabled));
|
||||||
|
d.push_text("Provision:DeviceEncryptionEnabled", b(p.devencenabled));
|
||||||
|
d.push_text("Provision:AttachmentsEnabled", b(p.attenabled));
|
||||||
|
d.push_text(
|
||||||
|
"Provision:MinDevicePasswordLength",
|
||||||
|
p.mindevpwlenngth.to_string(),
|
||||||
|
);
|
||||||
|
d.push_text(
|
||||||
|
"Provision:MaxInactivityTimeDeviceLock",
|
||||||
|
p.maxinacttimedevlock.to_string(),
|
||||||
|
);
|
||||||
|
d.push_text(
|
||||||
|
"Provision:MaxDevicePasswordFailedAttempts",
|
||||||
|
p.maxdevpwfailedattempts.to_string(),
|
||||||
|
);
|
||||||
|
if p.maxattsize >= 0 {
|
||||||
|
d.push_text("Provision:MaxAttachmentSize", p.maxattsize.to_string());
|
||||||
|
} else {
|
||||||
|
d.push(Element::new("Provision:MaxAttachmentSize"));
|
||||||
|
}
|
||||||
|
d.push_text("Provision:AllowSimpleDevicePassword", b(p.allowsimpledevpw));
|
||||||
|
if p.devpwexpiration > 0 {
|
||||||
|
d.push_text(
|
||||||
|
"Provision:DevicePasswordExpiration",
|
||||||
|
p.devpwexpiration.to_string(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
d.push(Element::new("Provision:DevicePasswordExpiration"));
|
||||||
|
}
|
||||||
|
d.push_text(
|
||||||
|
"Provision:DevicePasswordHistory",
|
||||||
|
p.devpwhistory.to_string(),
|
||||||
|
);
|
||||||
|
if ver >= 121 {
|
||||||
|
d.push_text("Provision:AllowStorageCard", b(p.allowstoragecard));
|
||||||
|
d.push_text("Provision:AllowCamera", b(p.allowcam));
|
||||||
|
d.push_text("Provision:RequireDeviceEncryption", b(p.reqdevenc));
|
||||||
|
d.push_text(
|
||||||
|
"Provision:AllowUnsignedApplications",
|
||||||
|
b(p.allowunsignedapps),
|
||||||
|
);
|
||||||
|
d.push_text(
|
||||||
|
"Provision:AllowUnsignedInstallationPackages",
|
||||||
|
b(p.allowunsignedinstallationpackages),
|
||||||
|
);
|
||||||
|
d.push_text(
|
||||||
|
"Provision:MinDevicePasswordComplexCharacters",
|
||||||
|
p.mindevcomplexchars.to_string(),
|
||||||
|
);
|
||||||
|
d.push_text("Provision:AllowWiFi", b(p.allowwifi));
|
||||||
|
d.push_text("Provision:AllowTextMessaging", b(p.allowtextmessaging));
|
||||||
|
d.push_text("Provision:AllowPOPIMAPEmail", b(p.allowpopimapemail));
|
||||||
|
d.push_text("Provision:AllowBluetooth", p.allowbluetooth.to_string());
|
||||||
|
d.push_text("Provision:AllowIrDA", b(p.allowirda));
|
||||||
|
d.push_text(
|
||||||
|
"Provision:RequireManualSyncWhenRoaming",
|
||||||
|
b(p.reqmansyncroam),
|
||||||
|
);
|
||||||
|
d.push_text("Provision:AllowDesktopSync", b(p.allowdesktopsync));
|
||||||
|
d.push_text(
|
||||||
|
"Provision:MaxCalendarAgeFilter",
|
||||||
|
p.maxcalagefilter.to_string(),
|
||||||
|
);
|
||||||
|
d.push_text("Provision:AllowHTMLEmail", b(p.allowhtmlemail));
|
||||||
|
d.push_text(
|
||||||
|
"Provision:MaxEmailAgeFilter",
|
||||||
|
p.maxemailagefilter.to_string(),
|
||||||
|
);
|
||||||
|
if p.maxemailbodytruncsize >= 0 {
|
||||||
|
d.push_text(
|
||||||
|
"Provision:MaxEmailBodyTruncationSize",
|
||||||
|
p.maxemailbodytruncsize.to_string(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
d.push(Element::new("Provision:MaxEmailBodyTruncationSize"));
|
||||||
|
}
|
||||||
|
if p.maxemailhtmlbodytruncsize >= 0 {
|
||||||
|
d.push_text(
|
||||||
|
"Provision:MaxEmailHTMLBodyTruncationSize",
|
||||||
|
p.maxemailhtmlbodytruncsize.to_string(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
d.push(Element::new("Provision:MaxEmailHTMLBodyTruncationSize"));
|
||||||
|
}
|
||||||
|
d.push_text(
|
||||||
|
"Provision:RequireSignedSMIMEMessages",
|
||||||
|
b(p.reqsignedsmimemessages),
|
||||||
|
);
|
||||||
|
d.push_text(
|
||||||
|
"Provision:RequireEncryptedSMIMEMessages",
|
||||||
|
b(p.reqencsmimemessages),
|
||||||
|
);
|
||||||
|
d.push_text(
|
||||||
|
"Provision:RequireSignedSMIMEAlgorithm",
|
||||||
|
p.reqsignedsmimealgorithm.to_string(),
|
||||||
|
);
|
||||||
|
d.push_text(
|
||||||
|
"Provision:RequireEncryptionSMIMEAlgorithm",
|
||||||
|
p.reqencsmimealgorithm.to_string(),
|
||||||
|
);
|
||||||
|
d.push_text(
|
||||||
|
"Provision:AllowSMIMEEncryptionAlgorithmNegotiation",
|
||||||
|
p.allowsmimeencalgneg.to_string(),
|
||||||
|
);
|
||||||
|
d.push_text("Provision:AllowSMIMESoftCerts", b(p.allowsmimesoftcerts));
|
||||||
|
d.push_text("Provision:AllowBrowser", b(p.allowbrowser));
|
||||||
|
d.push_text("Provision:AllowConsumerEmail", b(p.allowconsumeremail));
|
||||||
|
d.push_text("Provision:AllowRemoteDesktop", b(p.allowremotedesktop));
|
||||||
|
d.push_text("Provision:AllowInternetSharing", b(p.allowinternetsharing));
|
||||||
|
d.push(Element::new("Provision:UnapprovedInROMApplicationList"));
|
||||||
|
d.push(Element::new("Provision:ApprovedApplicationList"));
|
||||||
|
}
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn device_info_from(set: &Element) -> DeviceInfo {
|
||||||
|
let g = |n: &str| {
|
||||||
|
set.child_text(n)
|
||||||
|
.map(|s| crate::util::truncate_bytes(&s, 200).to_string())
|
||||||
|
};
|
||||||
|
DeviceInfo {
|
||||||
|
model: g("Settings:Model"),
|
||||||
|
imei: g("Settings:IMEI"),
|
||||||
|
friendly_name: g("Settings:FriendlyName"),
|
||||||
|
os: g("Settings:OS"),
|
||||||
|
os_language: g("Settings:OSLanguage"),
|
||||||
|
phone_number: g("Settings:PhoneNumber"),
|
||||||
|
mobile_operator: g("Settings:MobileOperator"),
|
||||||
|
user_agent: g("Settings:UserAgent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_key() -> u32 {
|
||||||
|
rand::thread_rng().gen_range(1_000_000..u32::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn provision(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||||
|
let Some(req) = req else {
|
||||||
|
return Ok(Reply::Wbxml(
|
||||||
|
Element::new("Provision:Provision").with_text("Provision:Status", "2"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let ver = ctx.req.ver();
|
||||||
|
let mut resp = Element::new("Provision:Provision").with_text("Provision:Status", "1");
|
||||||
|
|
||||||
|
if let Some(di) = req.child("Settings:DeviceInformation") {
|
||||||
|
if let Some(set) = di.child("Settings:Set") {
|
||||||
|
db::device_set_info(&ctx.app.db, ctx.dev.id, &device_info_from(set)).await?;
|
||||||
|
}
|
||||||
|
resp.push(Element::new("Settings:DeviceInformation").with_text("Settings:Status", "1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- remote wipe acknowledgement
|
||||||
|
if let Some(rw) = req
|
||||||
|
.child("Provision:RemoteWipe")
|
||||||
|
.or_else(|| req.child("Provision:AccountOnlyRemoteWipe"))
|
||||||
|
{
|
||||||
|
let st = rw.child_u32("Provision:Status").unwrap_or(0);
|
||||||
|
tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, status = st, "device acknowledged remote wipe");
|
||||||
|
db::device_wipe_done(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.dev.id,
|
||||||
|
if st == 1 {
|
||||||
|
db::WIPE_WIPED
|
||||||
|
} else {
|
||||||
|
db::WIPE_REQUESTED
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db::audit(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.user(),
|
||||||
|
"wipe_ack",
|
||||||
|
&ctx.req.device_id,
|
||||||
|
&ctx.ip,
|
||||||
|
&format!("status {st}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(Reply::Wbxml(resp));
|
||||||
|
}
|
||||||
|
|
||||||
|
let wipe = matches!(ctx.dev.wipe_status, db::WIPE_PENDING | db::WIPE_REQUESTED);
|
||||||
|
|
||||||
|
if let Some(pol) = req.path(&["Provision:Policies", "Provision:Policy"]) {
|
||||||
|
let ptype = pol.child_text("Provision:PolicyType").unwrap_or_default();
|
||||||
|
let mut pe =
|
||||||
|
Element::new("Provision:Policy").with_text("Provision:PolicyType", ptype.clone());
|
||||||
|
if ptype != "MS-EAS-Provisioning-WBXML" && ptype != "MS-WAP-Provisioning-XML" {
|
||||||
|
pe.push_text("Provision:Status", "3");
|
||||||
|
resp.push(Element::new("Provision:Policies").with(pe));
|
||||||
|
return Ok(Reply::Wbxml(resp));
|
||||||
|
}
|
||||||
|
if !ctx.s.provisioning.enabled {
|
||||||
|
pe.push_text("Provision:Status", "2");
|
||||||
|
resp.push(Element::new("Provision:Policies").with(pe));
|
||||||
|
return Ok(Reply::Wbxml(resp));
|
||||||
|
}
|
||||||
|
let policy = ctx.s.policy_for(ctx.user()).clone();
|
||||||
|
match pol
|
||||||
|
.child_text("Provision:PolicyKey")
|
||||||
|
.and_then(|k| k.parse::<u32>().ok())
|
||||||
|
{
|
||||||
|
None => {
|
||||||
|
// phase 1: deliver the policy with a temporary key
|
||||||
|
let temp = new_key();
|
||||||
|
db::device_set_field(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.dev.id,
|
||||||
|
DeviceField::PolicyTempKey,
|
||||||
|
Some(temp as i64),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
pe.push_text("Provision:Status", "1");
|
||||||
|
pe.push_text("Provision:PolicyKey", temp.to_string());
|
||||||
|
let data = if ptype == "MS-WAP-Provisioning-XML" {
|
||||||
|
Element::text(
|
||||||
|
"Provision:Data",
|
||||||
|
format!(
|
||||||
|
"<wap-provisioningdoc><characteristic type=\"SecurityPolicy\"><parm name=\"4131\" value=\"{}\"/><parm name=\"4133\" value=\"1\"/></characteristic></wap-provisioningdoc>",
|
||||||
|
if policy.devpwenabled { 0 } else { 1 }
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Element::new("Provision:Data").with(provision_doc(&policy, ver))
|
||||||
|
};
|
||||||
|
pe.push(data);
|
||||||
|
ctx.status = "policy sent".into();
|
||||||
|
}
|
||||||
|
Some(key) => {
|
||||||
|
// phase 2: acknowledgement
|
||||||
|
let client_status = pol.child_u32("Provision:Status").unwrap_or(1);
|
||||||
|
let ok_key = key == ctx.dev.policy_temp_key as u32
|
||||||
|
|| key == ctx.dev.policy_key as u32
|
||||||
|
|| ctx.dev.policy_temp_key == 0;
|
||||||
|
if !ok_key {
|
||||||
|
pe.push_text("Provision:Status", "5");
|
||||||
|
} else if client_status == 1 || (client_status == 2 && ctx.s.provisioning.loose) {
|
||||||
|
let fin = new_key();
|
||||||
|
db::device_set_field(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.dev.id,
|
||||||
|
DeviceField::PolicyKey,
|
||||||
|
Some(fin as i64),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db::device_set_field(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.dev.id,
|
||||||
|
DeviceField::PolicyTempKey,
|
||||||
|
Some(0),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db::device_set_field(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.dev.id,
|
||||||
|
DeviceField::PolicyName,
|
||||||
|
None,
|
||||||
|
Some(&policy.name),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db::device_set_field(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.dev.id,
|
||||||
|
DeviceField::PolicyHash,
|
||||||
|
None,
|
||||||
|
Some(&policy_hash(&policy)),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
pe.push_text("Provision:Status", "1");
|
||||||
|
pe.push_text("Provision:PolicyKey", fin.to_string());
|
||||||
|
ctx.status = "provisioned".into();
|
||||||
|
tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, policy = %policy.name, "device provisioned");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, client_status, "device could not apply policy");
|
||||||
|
pe.push_text("Provision:Status", "1");
|
||||||
|
pe.push_text("Provision:PolicyKey", "0");
|
||||||
|
ctx.status = format!("device rejected policy ({client_status})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.push(Element::new("Provision:Policies").with(pe));
|
||||||
|
}
|
||||||
|
|
||||||
|
if wipe {
|
||||||
|
resp.push(Element::new("Provision:RemoteWipe"));
|
||||||
|
db::device_set_field(
|
||||||
|
&ctx.app.db,
|
||||||
|
ctx.dev.id,
|
||||||
|
DeviceField::WipeStatus,
|
||||||
|
Some(db::WIPE_REQUESTED),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db::audit(
|
||||||
|
&ctx.app.db,
|
||||||
|
"system",
|
||||||
|
"wipe_sent",
|
||||||
|
&ctx.req.device_id,
|
||||||
|
&ctx.ip,
|
||||||
|
ctx.user(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, "remote wipe command sent to device");
|
||||||
|
ctx.status = "remote wipe sent".into();
|
||||||
|
}
|
||||||
|
Ok(Reply::Wbxml(resp))
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! Sync keys and persisted per-device state (folder hierarchy + item fingerprints).
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::db;
|
||||||
|
|
||||||
|
use super::convert::Options;
|
||||||
|
use super::Ctx;
|
||||||
|
|
||||||
|
pub const HIERARCHY: &str = "h";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum SyncKey {
|
||||||
|
Initial,
|
||||||
|
Key { uuid: String, counter: i64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncKey {
|
||||||
|
pub fn parse(s: &str) -> Option<SyncKey> {
|
||||||
|
let s = s.trim();
|
||||||
|
if s == "0" {
|
||||||
|
return Some(SyncKey::Initial);
|
||||||
|
}
|
||||||
|
let rest = s.strip_prefix('{')?;
|
||||||
|
let (uuid, counter) = rest.split_once('}')?;
|
||||||
|
if uuid.len() != 36 || !uuid.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(SyncKey::Key {
|
||||||
|
uuid: uuid.to_string(),
|
||||||
|
counter: counter.parse().ok()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_key(uuid: &str, counter: i64) -> String {
|
||||||
|
format!("{{{uuid}}}{counter}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_uuid() -> String {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct HierarchyState {
|
||||||
|
/// short id -> (parent short id, name, type)
|
||||||
|
pub folders: BTreeMap<String, (String, String, u8)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct CollectionState {
|
||||||
|
pub backend_id: String,
|
||||||
|
pub class: String,
|
||||||
|
pub meta: String,
|
||||||
|
pub filter: u8,
|
||||||
|
/// item id -> fingerprint, as known by the device
|
||||||
|
pub items: BTreeMap<String, String>,
|
||||||
|
/// client id -> server id of the last processed Adds (idempotent retries)
|
||||||
|
pub client_ids: Vec<(String, String)>,
|
||||||
|
/// Options remembered for empty/partial Sync requests
|
||||||
|
pub options: Option<Options>,
|
||||||
|
/// last backend signature (Ping)
|
||||||
|
pub signature: String,
|
||||||
|
/// number of consecutive retries of the same key (loop detection)
|
||||||
|
pub retries: u32,
|
||||||
|
pub window: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load<T: DeserializeOwned>(
|
||||||
|
ctx: &Ctx,
|
||||||
|
collection: &str,
|
||||||
|
key: &SyncKey,
|
||||||
|
) -> Result<Option<T>> {
|
||||||
|
let SyncKey::Key { uuid, counter } = key else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(data) = db::state_get(&ctx.app.db, ctx.dev.id, collection, uuid, *counter).await?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(serde_json::from_slice(&data).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_latest<T: DeserializeOwned>(
|
||||||
|
ctx: &Ctx,
|
||||||
|
collection: &str,
|
||||||
|
) -> Result<Option<(String, i64, T)>> {
|
||||||
|
let Some((uuid, counter, data)) = db::state_latest(&ctx.app.db, ctx.dev.id, collection).await?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(serde_json::from_slice(&data)
|
||||||
|
.ok()
|
||||||
|
.map(|t| (uuid, counter, t)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save<T: Serialize>(
|
||||||
|
ctx: &Ctx,
|
||||||
|
collection: &str,
|
||||||
|
uuid: &str,
|
||||||
|
counter: i64,
|
||||||
|
v: &T,
|
||||||
|
) -> Result<()> {
|
||||||
|
let data = serde_json::to_vec(v)?;
|
||||||
|
db::state_put(&ctx.app.db, ctx.dev.id, collection, uuid, counter, &data).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn keys() {
|
||||||
|
let u = new_uuid();
|
||||||
|
let k = make_key(&u, 5);
|
||||||
|
assert_eq!(
|
||||||
|
SyncKey::parse(&k),
|
||||||
|
Some(SyncKey::Key {
|
||||||
|
uuid: u,
|
||||||
|
counter: 5
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(SyncKey::parse("0"), Some(SyncKey::Initial));
|
||||||
|
assert_eq!(SyncKey::parse("garbage"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! ActivePush - Exchange ActiveSync gateway for IMAP/SMTP + CalDAV/CardDAV (Z-Push rewrite).
|
||||||
|
|
||||||
|
pub mod admin;
|
||||||
|
pub mod app;
|
||||||
|
pub mod autodiscover;
|
||||||
|
pub mod backend;
|
||||||
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod eas;
|
||||||
|
pub mod logging;
|
||||||
|
pub mod metrics;
|
||||||
|
pub mod probe;
|
||||||
|
pub mod server;
|
||||||
|
pub mod tls;
|
||||||
|
pub mod util;
|
||||||
|
pub mod wbxml;
|
||||||
|
pub mod xmltree;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::routing::any;
|
||||||
|
use axum::Router;
|
||||||
|
|
||||||
|
/// Public (device facing) router: ActiveSync + Autodiscover. Paths match the nginx
|
||||||
|
/// locations previously routed to Z-Push.
|
||||||
|
pub fn public_router(app: Arc<app::App>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/Microsoft-Server-ActiveSync", any(eas::handler))
|
||||||
|
.route("/microsoft-server-activesync", any(eas::handler))
|
||||||
|
.route("/AutoDiscover/AutoDiscover.xml", any(autodiscover::handler))
|
||||||
|
.route("/Autodiscover/Autodiscover.xml", any(autodiscover::handler))
|
||||||
|
.route("/autodiscover/autodiscover.xml", any(autodiscover::handler))
|
||||||
|
.route("/healthz", axum::routing::get(healthz))
|
||||||
|
.route("/", axum::routing::get(index))
|
||||||
|
.fallback(not_found)
|
||||||
|
.with_state(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index() -> impl axum::response::IntoResponse {
|
||||||
|
(
|
||||||
|
[("Content-Type", "text/plain; charset=utf-8")],
|
||||||
|
"ActivePush - Exchange ActiveSync endpoint.\nDevices connect to /Microsoft-Server-ActiveSync.\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn not_found() -> impl axum::response::IntoResponse {
|
||||||
|
(
|
||||||
|
axum::http::StatusCode::NOT_FOUND,
|
||||||
|
[("Content-Type", "text/plain; charset=utf-8")],
|
||||||
|
"Not found.\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liveness + database check for load balancers and monitoring.
|
||||||
|
async fn healthz(
|
||||||
|
axum::extract::State(app): axum::extract::State<Arc<app::App>>,
|
||||||
|
) -> impl axum::response::IntoResponse {
|
||||||
|
match sqlx::query_scalar::<_, i64>("SELECT 1")
|
||||||
|
.fetch_one(&app.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => (axum::http::StatusCode::OK, "ok\n"),
|
||||||
|
Err(_) => (
|
||||||
|
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"database unavailable\n",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! tracing setup + in-memory ring buffer exposed in the admin panel.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tracing::field::{Field, Visit};
|
||||||
|
use tracing::{Event, Level, Subscriber};
|
||||||
|
use tracing_subscriber::layer::{Context, SubscriberExt};
|
||||||
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
use tracing_subscriber::{EnvFilter, Layer};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LogLine {
|
||||||
|
pub ts: i64,
|
||||||
|
pub level: String,
|
||||||
|
pub target: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LogRing {
|
||||||
|
lines: Mutex<VecDeque<LogLine>>,
|
||||||
|
cap: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogRing {
|
||||||
|
pub fn new(cap: usize) -> Arc<Self> {
|
||||||
|
Arc::new(LogRing {
|
||||||
|
lines: Mutex::new(VecDeque::with_capacity(cap)),
|
||||||
|
cap: AtomicUsize::new(cap.max(100)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn set_cap(&self, cap: usize) {
|
||||||
|
self.cap.store(cap.clamp(100, 100_000), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
pub fn push(&self, l: LogLine) {
|
||||||
|
let cap = self.cap.load(Ordering::Relaxed);
|
||||||
|
let mut g = self.lines.lock().unwrap();
|
||||||
|
g.push_back(l);
|
||||||
|
while g.len() > cap {
|
||||||
|
g.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn tail(&self, n: usize, min_level: Option<&str>, q: Option<&str>) -> Vec<LogLine> {
|
||||||
|
let rank = |l: &str| match l {
|
||||||
|
"ERROR" => 5,
|
||||||
|
"WARN" => 4,
|
||||||
|
"INFO" => 3,
|
||||||
|
"DEBUG" => 2,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
let min = min_level.map(rank).unwrap_or(0);
|
||||||
|
let q = q.map(|s| s.to_lowercase());
|
||||||
|
let g = self.lines.lock().unwrap();
|
||||||
|
let mut out: Vec<LogLine> = g
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.filter(|l| rank(&l.level) >= min)
|
||||||
|
.filter(|l| {
|
||||||
|
q.as_ref()
|
||||||
|
.map(|q| l.message.to_lowercase().contains(q) || l.target.contains(q))
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
.take(n)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
out.reverse();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RingLayer {
|
||||||
|
ring: Arc<LogRing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MsgVisitor(String);
|
||||||
|
|
||||||
|
impl Visit for MsgVisitor {
|
||||||
|
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||||
|
if field.name() == "message" {
|
||||||
|
let _ = write!(self.0, "{value:?}");
|
||||||
|
} else {
|
||||||
|
let _ = write!(self.0, " {}={:?}", field.name(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn record_str(&mut self, field: &Field, value: &str) {
|
||||||
|
if field.name() == "message" {
|
||||||
|
self.0.push_str(value);
|
||||||
|
} else {
|
||||||
|
let _ = write!(self.0, " {}={}", field.name(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Subscriber> Layer<S> for RingLayer {
|
||||||
|
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||||
|
let meta = event.metadata();
|
||||||
|
if *meta.level() > Level::INFO && !meta.target().starts_with("active_push") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut v = MsgVisitor(String::new());
|
||||||
|
event.record(&mut v);
|
||||||
|
self.ring.push(LogLine {
|
||||||
|
ts: chrono::Utc::now().timestamp(),
|
||||||
|
level: meta.level().to_string(),
|
||||||
|
target: meta.target().to_string(),
|
||||||
|
message: v.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(ring: Arc<LogRing>, json: bool) {
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("active_push=info,warn"));
|
||||||
|
let ring_layer = RingLayer { ring }.with_filter(EnvFilter::new(
|
||||||
|
std::env::var("ACTIVE_PUSH_PANEL_LOG").unwrap_or_else(|_| "active_push=info,warn".into()),
|
||||||
|
));
|
||||||
|
let registry = tracing_subscriber::registry().with(ring_layer);
|
||||||
|
if json {
|
||||||
|
registry
|
||||||
|
.with(tracing_subscriber::fmt::layer().json().with_filter(filter))
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
registry
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
|
.with_target(false)
|
||||||
|
.with_filter(filter),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
use std::io::Read;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
use active_push::app::{App, Boot};
|
||||||
|
use active_push::{admin, db, logging, probe, public_router, server, tls};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "active-push",
|
||||||
|
version,
|
||||||
|
about = "ActivePush - Exchange ActiveSync server for IMAP/SMTP/CalDAV/CardDAV"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Data directory (SQLite database).
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "ACTIVE_PUSH_DATA_DIR",
|
||||||
|
default_value = "/var/lib/active-push",
|
||||||
|
global = true
|
||||||
|
)]
|
||||||
|
data_dir: PathBuf,
|
||||||
|
/// Device facing listener (put nginx/RustProxy in front of it).
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "ACTIVE_PUSH_EAS_LISTEN",
|
||||||
|
default_value = "127.0.0.1:8081",
|
||||||
|
global = true
|
||||||
|
)]
|
||||||
|
eas_listen: SocketAddr,
|
||||||
|
/// Administration panel listener.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "ACTIVE_PUSH_ADMIN_LISTEN",
|
||||||
|
default_value = "127.0.0.1:9091",
|
||||||
|
global = true
|
||||||
|
)]
|
||||||
|
admin_listen: SocketAddr,
|
||||||
|
/// Allow the panel to listen on a non-loopback address.
|
||||||
|
#[arg(long, env = "ACTIVE_PUSH_ALLOW_REMOTE_ADMIN", global = true)]
|
||||||
|
allow_remote_admin: bool,
|
||||||
|
/// Optional TLS for the panel (PEM files).
|
||||||
|
#[arg(long, env = "ACTIVE_PUSH_ADMIN_TLS_CERT", global = true)]
|
||||||
|
admin_tls_cert: Option<String>,
|
||||||
|
#[arg(long, env = "ACTIVE_PUSH_ADMIN_TLS_KEY", global = true)]
|
||||||
|
admin_tls_key: Option<String>,
|
||||||
|
/// Optional TLS for the ActiveSync listener (normally TLS ends at the reverse proxy).
|
||||||
|
#[arg(long, env = "ACTIVE_PUSH_EAS_TLS_CERT", global = true)]
|
||||||
|
eas_tls_cert: Option<String>,
|
||||||
|
#[arg(long, env = "ACTIVE_PUSH_EAS_TLS_KEY", global = true)]
|
||||||
|
eas_tls_key: Option<String>,
|
||||||
|
/// JSON log output (for journald/ELK).
|
||||||
|
#[arg(long, env = "ACTIVE_PUSH_LOG_JSON", global = true)]
|
||||||
|
log_json: bool,
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Option<Cmd>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Run the server (default).
|
||||||
|
Serve,
|
||||||
|
/// Administrator management.
|
||||||
|
Admin {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: AdminCmd,
|
||||||
|
},
|
||||||
|
/// Settings import/export.
|
||||||
|
Config {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: ConfigCmd,
|
||||||
|
},
|
||||||
|
/// Device management (z-push-admin equivalents).
|
||||||
|
Device {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: DeviceCmd,
|
||||||
|
},
|
||||||
|
/// ActiveSync smoke test against a running server.
|
||||||
|
Probe {
|
||||||
|
#[arg(long)]
|
||||||
|
url: String,
|
||||||
|
#[arg(long)]
|
||||||
|
user: String,
|
||||||
|
/// Read the password from this environment variable (otherwise prompt).
|
||||||
|
#[arg(long)]
|
||||||
|
password_env: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
insecure: bool,
|
||||||
|
/// Device id used for the test partnership.
|
||||||
|
#[arg(long, default_value = "probe0000000001")]
|
||||||
|
device_id: String,
|
||||||
|
#[arg(long, default_value = "ActivePushProbe")]
|
||||||
|
device_type: String,
|
||||||
|
/// Model reported via DeviceInformation.
|
||||||
|
#[arg(long, default_value = "ActivePush probe")]
|
||||||
|
model: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum AdminCmd {
|
||||||
|
/// Print a one-time setup token (valid 1 hour) for creating the first admin in the panel.
|
||||||
|
SetupToken,
|
||||||
|
/// Create the first administrator (password is prompted).
|
||||||
|
Init {
|
||||||
|
#[arg(long)]
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
/// Reset an administrator password and revoke all sessions.
|
||||||
|
ResetPassword {
|
||||||
|
#[arg(long)]
|
||||||
|
username: Option<String>,
|
||||||
|
},
|
||||||
|
/// Revoke all panel sessions.
|
||||||
|
LogoutAll,
|
||||||
|
Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ConfigCmd {
|
||||||
|
/// Print current settings as JSON (secrets redacted unless --with-secrets).
|
||||||
|
Get {
|
||||||
|
#[arg(long)]
|
||||||
|
with_secrets: bool,
|
||||||
|
},
|
||||||
|
/// Validate and store settings from a JSON file ("-" = stdin). A running server picks
|
||||||
|
/// them up on restart; use the panel for live changes.
|
||||||
|
Apply { file: String },
|
||||||
|
/// Validate a JSON settings file without storing it.
|
||||||
|
Validate { file: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum DeviceCmd {
|
||||||
|
List,
|
||||||
|
/// Request a remote wipe.
|
||||||
|
Wipe {
|
||||||
|
id: i64,
|
||||||
|
},
|
||||||
|
/// Forget all sync states (device resynchronises everything).
|
||||||
|
Resync {
|
||||||
|
id: i64,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
id: i64,
|
||||||
|
},
|
||||||
|
Block {
|
||||||
|
id: i64,
|
||||||
|
},
|
||||||
|
Unblock {
|
||||||
|
id: i64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_password(prompt: &str) -> Result<String> {
|
||||||
|
let p1 = rpassword::prompt_password(prompt)?;
|
||||||
|
let p2 = rpassword::prompt_password("Repeat password: ")?;
|
||||||
|
if p1 != p2 {
|
||||||
|
bail!("passwords do not match");
|
||||||
|
}
|
||||||
|
Ok(p1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_file(f: &str) -> Result<String> {
|
||||||
|
if f == "-" {
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_to_string(&mut s)?;
|
||||||
|
Ok(s)
|
||||||
|
} else {
|
||||||
|
std::fs::read_to_string(f).with_context(|| format!("read {f}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
rt.block_on(run(cli))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(cli: Cli) -> Result<()> {
|
||||||
|
let ring = logging::LogRing::new(2000);
|
||||||
|
let cmd = cli.cmd.unwrap_or(Cmd::Serve);
|
||||||
|
if matches!(cmd, Cmd::Serve) {
|
||||||
|
logging::init(ring.clone(), cli.log_json);
|
||||||
|
} else {
|
||||||
|
// CLI commands: quiet logging to stderr
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::new(
|
||||||
|
std::env::var("RUST_LOG").unwrap_or_else(|_| "warn".into()),
|
||||||
|
))
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.try_init();
|
||||||
|
}
|
||||||
|
if let Cmd::Probe {
|
||||||
|
url,
|
||||||
|
user,
|
||||||
|
password_env,
|
||||||
|
insecure,
|
||||||
|
device_id,
|
||||||
|
device_type,
|
||||||
|
model,
|
||||||
|
} = &cmd
|
||||||
|
{
|
||||||
|
let pass = match password_env {
|
||||||
|
Some(v) => {
|
||||||
|
std::env::var(v).with_context(|| format!("environment variable {v} not set"))?
|
||||||
|
}
|
||||||
|
None => rpassword::prompt_password("Password: ")?,
|
||||||
|
};
|
||||||
|
return probe::run(url, user, &pass, *insecure, device_id, device_type, model).await;
|
||||||
|
}
|
||||||
|
let boot = Boot {
|
||||||
|
data_dir: cli.data_dir.clone(),
|
||||||
|
eas_listen: cli.eas_listen,
|
||||||
|
admin_listen: cli.admin_listen,
|
||||||
|
allow_remote_admin: cli.allow_remote_admin,
|
||||||
|
admin_tls: cli.admin_tls_cert.clone().zip(cli.admin_tls_key.clone()),
|
||||||
|
eas_tls: cli.eas_tls_cert.clone().zip(cli.eas_tls_key.clone()),
|
||||||
|
};
|
||||||
|
check_data_dir_owner(&boot.data_dir)?;
|
||||||
|
let app = App::new(boot, ring).await?;
|
||||||
|
match cmd {
|
||||||
|
Cmd::Serve => serve(app).await,
|
||||||
|
Cmd::Admin { cmd } => match cmd {
|
||||||
|
AdminCmd::SetupToken => {
|
||||||
|
let t = admin::cli_setup_token(&app).await?;
|
||||||
|
println!("{t}");
|
||||||
|
eprintln!("One-time setup token (valid 60 minutes). Open the panel and paste it to create the administrator.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
AdminCmd::Init { username } => {
|
||||||
|
let pw = read_password("New administrator password (min. 12 characters): ")?;
|
||||||
|
admin::cli_init(&app, &username, &pw).await?;
|
||||||
|
println!("Administrator {username} created.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
AdminCmd::ResetPassword { username } => {
|
||||||
|
let pw = read_password("New password (min. 12 characters): ")?;
|
||||||
|
let u = admin::cli_reset_password(&app, username.as_deref(), &pw).await?;
|
||||||
|
println!("Password for {u} changed; all panel sessions were revoked.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
AdminCmd::LogoutAll => {
|
||||||
|
let n = admin::cli_logout_all(&app).await?;
|
||||||
|
println!("{n} session(s) revoked.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
AdminCmd::Status => {
|
||||||
|
println!("{}", admin::cli_status(&app).await?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Cmd::Config { cmd } => match cmd {
|
||||||
|
ConfigCmd::Get { with_secrets } => {
|
||||||
|
let s = app.settings();
|
||||||
|
let v = if with_secrets {
|
||||||
|
serde_json::to_value(&*s)?
|
||||||
|
} else {
|
||||||
|
s.redacted()
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string_pretty(&v)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
ConfigCmd::Apply { file } => {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&read_file(&file)?)?;
|
||||||
|
let s = app
|
||||||
|
.settings()
|
||||||
|
.merge_update(v)
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
let rev = app.update_settings(s).await?;
|
||||||
|
db::audit(
|
||||||
|
&app.db,
|
||||||
|
"cli",
|
||||||
|
"settings_saved",
|
||||||
|
"",
|
||||||
|
"cli",
|
||||||
|
&format!("revision {rev}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
println!("Settings stored (revision {rev}). Restart the service or save in the panel to apply live.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
ConfigCmd::Validate { file } => {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&read_file(&file)?)?;
|
||||||
|
app.settings()
|
||||||
|
.merge_update(v)
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
println!("Settings are valid.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Cmd::Device { cmd } => {
|
||||||
|
match cmd {
|
||||||
|
DeviceCmd::List => {
|
||||||
|
println!(
|
||||||
|
"{:>5} {:<28} {:<16} {:<30} {:<19} STATE",
|
||||||
|
"ID", "USER", "TYPE", "DEVICE ID", "LAST SEEN"
|
||||||
|
);
|
||||||
|
for d in db::device_list(&app.db).await? {
|
||||||
|
let state = if d.blocked == 1 {
|
||||||
|
"blocked"
|
||||||
|
} else if d.wipe_status == db::WIPE_WIPED {
|
||||||
|
"wiped"
|
||||||
|
} else if d.wipe_status >= db::WIPE_PENDING {
|
||||||
|
"wipe pending"
|
||||||
|
} else if d.approved == 0 {
|
||||||
|
"awaiting approval"
|
||||||
|
} else {
|
||||||
|
"ok"
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{:>5} {:<28} {:<16} {:<30} {:<19} {state}",
|
||||||
|
d.id,
|
||||||
|
d.username,
|
||||||
|
d.device_type,
|
||||||
|
d.device_id,
|
||||||
|
active_push::util::format_ts(d.last_seen)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DeviceCmd::Wipe { id } => {
|
||||||
|
db::device_request_wipe(&app.db, id, "cli", false).await?;
|
||||||
|
db::audit(&app.db, "cli", "device_wipe", &id.to_string(), "cli", "").await;
|
||||||
|
println!("Remote wipe requested for device {id}; it is executed on the next device connection.");
|
||||||
|
}
|
||||||
|
DeviceCmd::Resync { id } => {
|
||||||
|
db::device_reset_states(&app.db, id).await?;
|
||||||
|
println!("States of device {id} removed.");
|
||||||
|
}
|
||||||
|
DeviceCmd::Remove { id } => {
|
||||||
|
db::device_delete(&app.db, id).await?;
|
||||||
|
println!("Device {id} removed.");
|
||||||
|
}
|
||||||
|
DeviceCmd::Block { id } => {
|
||||||
|
db::device_set_field(&app.db, id, db::DeviceField::Blocked, Some(1), None)
|
||||||
|
.await?;
|
||||||
|
println!("Device {id} blocked.");
|
||||||
|
}
|
||||||
|
DeviceCmd::Unblock { id } => {
|
||||||
|
db::device_set_field(&app.db, id, db::DeviceField::Blocked, Some(0), None)
|
||||||
|
.await?;
|
||||||
|
println!("Device {id} unblocked.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Cmd::Probe { .. } => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse to touch a data directory owned by another user (e.g. `active-push admin …` run as
|
||||||
|
/// root against the service's /var/lib/active-push would leave root-owned database files).
|
||||||
|
fn check_data_dir_owner(dir: &std::path::Path) -> Result<()> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let Ok(meta) = std::fs::metadata(dir) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let euid = std::fs::read_to_string("/proc/self/status")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| {
|
||||||
|
s.lines().find(|l| l.starts_with("Uid:")).and_then(|l| {
|
||||||
|
l.split_whitespace()
|
||||||
|
.nth(2)
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if let Some(euid) = euid {
|
||||||
|
if euid != meta.uid() && std::env::var("ACTIVE_PUSH_ALLOW_FOREIGN_DATA_DIR").is_err() {
|
||||||
|
bail!(
|
||||||
|
"{} belongs to uid {}, but this command runs as uid {euid}.\nRun it as the service user, e.g.: sudo -u active-push active-push …",
|
||||||
|
dir.display(),
|
||||||
|
meta.uid()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve(app: Arc<App>) -> Result<()> {
|
||||||
|
let boot = app.boot.clone();
|
||||||
|
if !boot.admin_listen.ip().is_loopback() && !boot.allow_remote_admin {
|
||||||
|
bail!(
|
||||||
|
"refusing to expose the admin panel on {} - use a loopback address, an SSH tunnel/reverse proxy, or pass --allow-remote-admin",
|
||||||
|
boot.admin_listen
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let admin_tls = match &boot.admin_tls {
|
||||||
|
Some((c, k)) => Some(tls::server_config(c, k)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let eas_tls = match &boot.eas_tls {
|
||||||
|
Some((c, k)) => Some(tls::server_config(c, k)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let eas_l = tokio::net::TcpListener::bind(boot.eas_listen)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("bind ActiveSync listener {}", boot.eas_listen))?;
|
||||||
|
let adm_l = tokio::net::TcpListener::bind(boot.admin_listen)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("bind admin listener {}", boot.admin_listen))?;
|
||||||
|
let s = app.settings();
|
||||||
|
tracing::info!(
|
||||||
|
version = env!("CARGO_PKG_VERSION"),
|
||||||
|
eas = %boot.eas_listen,
|
||||||
|
panel = %boot.admin_listen,
|
||||||
|
imap = %format!("{}:{}", s.imap.host, s.imap.port),
|
||||||
|
caldav = s.caldav.enabled,
|
||||||
|
carddav = s.carddav.enabled,
|
||||||
|
"ActivePush started"
|
||||||
|
);
|
||||||
|
let has_admin: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM admins")
|
||||||
|
.fetch_one(&app.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
if has_admin == 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
"no administrator yet - run `active-push admin setup-token` and open the panel"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// background housekeeping
|
||||||
|
let bg = app.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(60));
|
||||||
|
let mut n: u64 = 0;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tick.tick() => {}
|
||||||
|
_ = bg.shutdown.cancelled() => break,
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
let s = bg.settings();
|
||||||
|
bg.pool
|
||||||
|
.evict_idle(Duration::from_secs(s.imap.pool_idle_secs));
|
||||||
|
bg.auth.housekeeping();
|
||||||
|
bg.metrics.prune();
|
||||||
|
if n % 5 == 0 {
|
||||||
|
bg.metrics.flush_hour(&bg.db).await;
|
||||||
|
}
|
||||||
|
if n % 60 == 1 {
|
||||||
|
if let Err(e) = db::housekeeping(&bg.db, &s).await {
|
||||||
|
tracing::warn!("housekeeping failed: {e:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let shutdown = app.shutdown.clone();
|
||||||
|
let eas = tokio::spawn(server::serve(
|
||||||
|
"activesync",
|
||||||
|
eas_l,
|
||||||
|
public_router(app.clone()),
|
||||||
|
eas_tls,
|
||||||
|
shutdown.clone(),
|
||||||
|
));
|
||||||
|
let adm = tokio::spawn(server::serve(
|
||||||
|
"panel",
|
||||||
|
adm_l,
|
||||||
|
admin::router(app.clone()),
|
||||||
|
admin_tls,
|
||||||
|
shutdown.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::signal::ctrl_c() => {}
|
||||||
|
_ = term.recv() => {}
|
||||||
|
}
|
||||||
|
tracing::info!("shutdown requested");
|
||||||
|
shutdown.cancel();
|
||||||
|
let _ = tokio::join!(eas, adm);
|
||||||
|
app.metrics.flush_hour(&app.db).await;
|
||||||
|
app.db.close().await;
|
||||||
|
tracing::info!("bye");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
//! In-memory metrics, live request registry and per-device ping tracking.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::sync::{broadcast, Notify};
|
||||||
|
|
||||||
|
use crate::db::Db;
|
||||||
|
use crate::util::now;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Counters {
|
||||||
|
pub requests: AtomicU64,
|
||||||
|
pub errors: AtomicU64,
|
||||||
|
pub auth_failures: AtomicU64,
|
||||||
|
pub bytes_in: AtomicU64,
|
||||||
|
pub bytes_out: AtomicU64,
|
||||||
|
pub items_out: AtomicU64,
|
||||||
|
pub items_in: AtomicU64,
|
||||||
|
pub mails_sent: AtomicU64,
|
||||||
|
pub autodiscover: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
pub struct CmdStat {
|
||||||
|
pub count: u64,
|
||||||
|
pub errors: u64,
|
||||||
|
pub total_ms: u64,
|
||||||
|
pub max_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
pub struct Bucket {
|
||||||
|
pub t: i64,
|
||||||
|
pub requests: u64,
|
||||||
|
pub errors: u64,
|
||||||
|
pub auth_failures: u64,
|
||||||
|
pub items_out: u64,
|
||||||
|
pub items_in: u64,
|
||||||
|
pub bytes_in: u64,
|
||||||
|
pub bytes_out: u64,
|
||||||
|
pub total_ms: u64,
|
||||||
|
pub pings: u64,
|
||||||
|
pub mails_sent: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ActiveRequest {
|
||||||
|
pub id: u64,
|
||||||
|
pub user: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub device_type: String,
|
||||||
|
pub command: String,
|
||||||
|
pub ip: String,
|
||||||
|
pub started: i64,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Event {
|
||||||
|
pub ts: i64,
|
||||||
|
pub user: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub device_type: String,
|
||||||
|
pub command: String,
|
||||||
|
pub ip: String,
|
||||||
|
pub http: u16,
|
||||||
|
pub status: String,
|
||||||
|
pub ms: u64,
|
||||||
|
pub bytes_in: u64,
|
||||||
|
pub bytes_out: u64,
|
||||||
|
pub items_out: u64,
|
||||||
|
pub items_in: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PingInfo {
|
||||||
|
pub user: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub device_type: String,
|
||||||
|
pub started: i64,
|
||||||
|
pub lifetime: u64,
|
||||||
|
pub folders: usize,
|
||||||
|
pub kind: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PingSlot {
|
||||||
|
info: PingInfo,
|
||||||
|
cancel: Arc<Notify>,
|
||||||
|
gen: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Metrics {
|
||||||
|
pub c: Counters,
|
||||||
|
commands: Mutex<HashMap<String, CmdStat>>,
|
||||||
|
minutes: Mutex<VecDeque<Bucket>>,
|
||||||
|
hour_acc: Mutex<(i64, Bucket, HashMap<String, u64>)>,
|
||||||
|
active: Mutex<HashMap<u64, ActiveRequest>>,
|
||||||
|
next_id: AtomicU64,
|
||||||
|
recent: Mutex<VecDeque<Event>>,
|
||||||
|
pub events: broadcast::Sender<Event>,
|
||||||
|
pings: Mutex<HashMap<String, PingSlot>>,
|
||||||
|
ping_gen: AtomicU64,
|
||||||
|
users_seen: Mutex<HashMap<String, i64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Metrics {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metrics {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (tx, _) = broadcast::channel(512);
|
||||||
|
Metrics {
|
||||||
|
c: Counters::default(),
|
||||||
|
commands: Mutex::new(HashMap::new()),
|
||||||
|
minutes: Mutex::new(VecDeque::with_capacity(1441)),
|
||||||
|
hour_acc: Mutex::new((now() / 3600 * 3600, Bucket::default(), HashMap::new())),
|
||||||
|
active: Mutex::new(HashMap::new()),
|
||||||
|
next_id: AtomicU64::new(1),
|
||||||
|
recent: Mutex::new(VecDeque::with_capacity(501)),
|
||||||
|
events: tx,
|
||||||
|
pings: Mutex::new(HashMap::new()),
|
||||||
|
ping_gen: AtomicU64::new(1),
|
||||||
|
users_seen: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_bucket(&self, f: impl FnOnce(&mut Bucket)) {
|
||||||
|
let minute = now() / 60 * 60;
|
||||||
|
let mut m = self.minutes.lock().unwrap();
|
||||||
|
if m.back().map(|b| b.t) != Some(minute) {
|
||||||
|
m.push_back(Bucket {
|
||||||
|
t: minute,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
while m.len() > 1440 {
|
||||||
|
m.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f(m.back_mut().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn begin(&self, r: ActiveRequest) -> u64 {
|
||||||
|
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let mut r = r;
|
||||||
|
r.id = id;
|
||||||
|
if !r.user.is_empty() {
|
||||||
|
self.users_seen
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(r.user.clone(), now());
|
||||||
|
}
|
||||||
|
self.active.lock().unwrap().insert(id, r);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end(&self, id: u64, ev: Event) {
|
||||||
|
self.active.lock().unwrap().remove(&id);
|
||||||
|
self.record(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record(&self, ev: Event) {
|
||||||
|
let failed = ev.http >= 500 || ev.status.starts_with("error");
|
||||||
|
self.c.requests.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if failed {
|
||||||
|
self.c.errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
self.c.bytes_in.fetch_add(ev.bytes_in, Ordering::Relaxed);
|
||||||
|
self.c.bytes_out.fetch_add(ev.bytes_out, Ordering::Relaxed);
|
||||||
|
self.c.items_out.fetch_add(ev.items_out, Ordering::Relaxed);
|
||||||
|
self.c.items_in.fetch_add(ev.items_in, Ordering::Relaxed);
|
||||||
|
{
|
||||||
|
let mut cmds = self.commands.lock().unwrap();
|
||||||
|
let s = cmds.entry(ev.command.clone()).or_default();
|
||||||
|
s.count += 1;
|
||||||
|
if failed {
|
||||||
|
s.errors += 1;
|
||||||
|
}
|
||||||
|
// pings/hanging syncs would skew latency
|
||||||
|
if ev.command != "Ping" {
|
||||||
|
s.total_ms += ev.ms;
|
||||||
|
s.max_ms = s.max_ms.max(ev.ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let is_ping = ev.command == "Ping";
|
||||||
|
self.with_bucket(|b| {
|
||||||
|
b.requests += 1;
|
||||||
|
if failed {
|
||||||
|
b.errors += 1;
|
||||||
|
}
|
||||||
|
b.items_out += ev.items_out;
|
||||||
|
b.items_in += ev.items_in;
|
||||||
|
b.bytes_in += ev.bytes_in;
|
||||||
|
b.bytes_out += ev.bytes_out;
|
||||||
|
if is_ping {
|
||||||
|
b.pings += 1;
|
||||||
|
} else {
|
||||||
|
b.total_ms += ev.ms;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let mut h = self.hour_acc.lock().unwrap();
|
||||||
|
h.1.requests += 1;
|
||||||
|
if failed {
|
||||||
|
h.1.errors += 1;
|
||||||
|
}
|
||||||
|
h.1.bytes_in += ev.bytes_in;
|
||||||
|
h.1.bytes_out += ev.bytes_out;
|
||||||
|
h.1.items_out += ev.items_out;
|
||||||
|
h.1.items_in += ev.items_in;
|
||||||
|
if !is_ping {
|
||||||
|
h.1.total_ms += ev.ms;
|
||||||
|
}
|
||||||
|
*h.2.entry(ev.command.clone()).or_default() += 1;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut r = self.recent.lock().unwrap();
|
||||||
|
r.push_back(ev.clone());
|
||||||
|
while r.len() > 500 {
|
||||||
|
r.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = self.events.send(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auth_failure(&self) {
|
||||||
|
self.c.auth_failures.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.with_bucket(|b| b.auth_failures += 1);
|
||||||
|
self.hour_acc.lock().unwrap().1.auth_failures += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mail_sent(&self) {
|
||||||
|
self.c.mails_sent.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.with_bucket(|b| b.mails_sent += 1);
|
||||||
|
self.hour_acc.lock().unwrap().1.mails_sent += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active(&self) -> Vec<ActiveRequest> {
|
||||||
|
let mut v: Vec<_> = self.active.lock().unwrap().values().cloned().collect();
|
||||||
|
v.sort_by_key(|a| a.started);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recent(&self, n: usize) -> Vec<Event> {
|
||||||
|
let r = self.recent.lock().unwrap();
|
||||||
|
r.iter().rev().take(n).cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn minutes(&self, n: usize) -> Vec<Bucket> {
|
||||||
|
let m = self.minutes.lock().unwrap();
|
||||||
|
let skip = m.len().saturating_sub(n);
|
||||||
|
m.iter().skip(skip).cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commands(&self) -> HashMap<String, CmdStat> {
|
||||||
|
self.commands.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop per-user activity entries older than a day (bounded memory).
|
||||||
|
pub fn prune(&self) {
|
||||||
|
let cutoff = now() - 86400;
|
||||||
|
self.users_seen.lock().unwrap().retain(|_, t| *t >= cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn users_active(&self, window_secs: i64) -> usize {
|
||||||
|
let cutoff = now() - window_secs;
|
||||||
|
self.users_seen
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.filter(|t| **t >= cutoff)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- pings
|
||||||
|
|
||||||
|
/// Register a hanging request for a device. Any previous hanging request of the same
|
||||||
|
/// device is cancelled (the device opened a new connection).
|
||||||
|
pub fn ping_start(&self, key: &str, info: PingInfo) -> (Arc<Notify>, u64) {
|
||||||
|
let gen = self.ping_gen.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let cancel = Arc::new(Notify::new());
|
||||||
|
let mut p = self.pings.lock().unwrap();
|
||||||
|
if let Some(old) = p.insert(
|
||||||
|
key.to_string(),
|
||||||
|
PingSlot {
|
||||||
|
info,
|
||||||
|
cancel: cancel.clone(),
|
||||||
|
gen,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
old.cancel.notify_one();
|
||||||
|
}
|
||||||
|
(cancel, gen)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ping_end(&self, key: &str, gen: u64) {
|
||||||
|
let mut p = self.pings.lock().unwrap();
|
||||||
|
if p.get(key).map(|s| s.gen) == Some(gen) {
|
||||||
|
p.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new request from a device interrupts its hanging Ping/Sync.
|
||||||
|
pub fn ping_interrupt(&self, key: &str) {
|
||||||
|
if let Some(s) = self.pings.lock().unwrap().remove(key) {
|
||||||
|
s.cancel.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pings(&self) -> Vec<PingInfo> {
|
||||||
|
self.pings
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.map(|s| s.info.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ping_count(&self) -> usize {
|
||||||
|
self.pings.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- persistence
|
||||||
|
|
||||||
|
/// Flush the current hour accumulator into SQLite (called periodically).
|
||||||
|
pub async fn flush_hour(&self, db: &Db) {
|
||||||
|
let (hour, b, cmds) = {
|
||||||
|
let mut h = self.hour_acc.lock().unwrap();
|
||||||
|
let cur_hour = now() / 3600 * 3600;
|
||||||
|
let snapshot = (h.0, std::mem::take(&mut h.1), std::mem::take(&mut h.2));
|
||||||
|
h.0 = cur_hour;
|
||||||
|
snapshot
|
||||||
|
};
|
||||||
|
if b.requests == 0 && b.auth_failures == 0 && b.mails_sent == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// merge command json
|
||||||
|
let existing: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT commands FROM stats_hourly WHERE hour = ?")
|
||||||
|
.bind(hour)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let mut merged: HashMap<String, u64> = existing
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for (k, v) in cmds {
|
||||||
|
*merged.entry(k).or_default() += v;
|
||||||
|
}
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO stats_hourly (hour, requests, errors, auth_failures, bytes_in, bytes_out, items_out, items_in, mails_sent, total_ms, commands)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(hour) DO UPDATE SET requests = requests + excluded.requests, errors = errors + excluded.errors,
|
||||||
|
auth_failures = auth_failures + excluded.auth_failures, bytes_in = bytes_in + excluded.bytes_in,
|
||||||
|
bytes_out = bytes_out + excluded.bytes_out, items_out = items_out + excluded.items_out,
|
||||||
|
items_in = items_in + excluded.items_in, mails_sent = mails_sent + excluded.mails_sent,
|
||||||
|
total_ms = total_ms + excluded.total_ms, commands = excluded.commands",
|
||||||
|
)
|
||||||
|
.bind(hour)
|
||||||
|
.bind(b.requests as i64)
|
||||||
|
.bind(b.errors as i64)
|
||||||
|
.bind(b.auth_failures as i64)
|
||||||
|
.bind(b.bytes_in as i64)
|
||||||
|
.bind(b.bytes_out as i64)
|
||||||
|
.bind(b.items_out as i64)
|
||||||
|
.bind(b.items_in as i64)
|
||||||
|
.bind(b.mails_sent as i64)
|
||||||
|
.bind(b.total_ms as i64)
|
||||||
|
.bind(serde_json::to_string(&merged).unwrap_or_default())
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple sliding-window failure counter + lockout (per IP and per user).
|
||||||
|
pub struct AuthGuard {
|
||||||
|
fails: Mutex<HashMap<String, VecDeque<i64>>>,
|
||||||
|
locked: Mutex<HashMap<String, i64>>,
|
||||||
|
ok_cache: Mutex<HashMap<[u8; 32], i64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthGuard {
|
||||||
|
fn default() -> Self {
|
||||||
|
AuthGuard {
|
||||||
|
fails: Mutex::new(HashMap::new()),
|
||||||
|
locked: Mutex::new(HashMap::new()),
|
||||||
|
ok_cache: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthGuard {
|
||||||
|
pub fn locked_until(&self, key: &str) -> Option<i64> {
|
||||||
|
let mut l = self.locked.lock().unwrap();
|
||||||
|
match l.get(key) {
|
||||||
|
Some(t) if *t > now() => Some(*t),
|
||||||
|
Some(_) => {
|
||||||
|
l.remove(key);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a failure; returns true if the key just became locked.
|
||||||
|
pub fn fail(&self, key: &str, max: u32, window: u64, lockout: u64) -> bool {
|
||||||
|
let t = now();
|
||||||
|
let mut f = self.fails.lock().unwrap();
|
||||||
|
let q = f.entry(key.to_string()).or_default();
|
||||||
|
q.push_back(t);
|
||||||
|
while q.front().map(|x| *x < t - window as i64).unwrap_or(false) {
|
||||||
|
q.pop_front();
|
||||||
|
}
|
||||||
|
if max > 0 && q.len() as u32 >= max {
|
||||||
|
q.clear();
|
||||||
|
self.locked
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(key.to_string(), t + lockout as i64);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn success(&self, key: &str) {
|
||||||
|
self.fails.lock().unwrap().remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache_ok(&self, k: [u8; 32], ttl: u64) {
|
||||||
|
if ttl > 0 {
|
||||||
|
self.ok_cache.lock().unwrap().insert(k, now() + ttl as i64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_cached(&self, k: &[u8; 32]) -> bool {
|
||||||
|
self.ok_cache
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(k)
|
||||||
|
.map(|t| *t > now())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_cache(&self) {
|
||||||
|
self.ok_cache.lock().unwrap().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lockouts(&self) -> Vec<(String, i64)> {
|
||||||
|
let t = now();
|
||||||
|
self.locked
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, v)| **v > t)
|
||||||
|
.map(|(k, v)| (k.clone(), *v))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unlock(&self, key: &str) {
|
||||||
|
self.locked.lock().unwrap().remove(key);
|
||||||
|
self.fails.lock().unwrap().remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn housekeeping(&self) {
|
||||||
|
let t = now();
|
||||||
|
self.locked.lock().unwrap().retain(|_, v| *v > t);
|
||||||
|
self.ok_cache.lock().unwrap().retain(|_, v| *v > t);
|
||||||
|
self.fails
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|_, q| q.back().map(|x| *x > t - 86400).unwrap_or(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
//! `active-push probe` - a small ActiveSync client used for smoke tests and diagnostics.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
use crate::wbxml::{self, Element};
|
||||||
|
|
||||||
|
pub struct Probe {
|
||||||
|
http: reqwest::Client,
|
||||||
|
url: String,
|
||||||
|
user: String,
|
||||||
|
pass: String,
|
||||||
|
device: String,
|
||||||
|
pub device_type: String,
|
||||||
|
pub model: String,
|
||||||
|
pub policy_key: String,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Probe {
|
||||||
|
pub fn new(url: &str, user: &str, pass: &str, device: &str, insecure: bool) -> Result<Self> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.danger_accept_invalid_certs(insecure)
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.build()?;
|
||||||
|
Ok(Probe {
|
||||||
|
http,
|
||||||
|
url: url.trim_end_matches('/').to_string(),
|
||||||
|
user: user.into(),
|
||||||
|
pass: pass.into(),
|
||||||
|
device: device.into(),
|
||||||
|
device_type: "ActivePushProbe".into(),
|
||||||
|
model: "ActivePush probe".into(),
|
||||||
|
policy_key: "0".into(),
|
||||||
|
version: "14.1".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn options(&self) -> Result<(u16, String, String)> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.request(reqwest::Method::OPTIONS, &self.url)
|
||||||
|
.basic_auth(&self.user, Some(&self.pass))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let h = |n: &str| {
|
||||||
|
r.headers()
|
||||||
|
.get(n)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
Ok((
|
||||||
|
r.status().as_u16(),
|
||||||
|
h("ms-asprotocolversions"),
|
||||||
|
h("ms-asprotocolcommands"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd(&self, cmd: &str, body: Option<Element>) -> Result<(u16, Option<Element>)> {
|
||||||
|
self.cmd_q(cmd, body, "").await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_q(
|
||||||
|
&self,
|
||||||
|
cmd: &str,
|
||||||
|
body: Option<Element>,
|
||||||
|
extra_q: &str,
|
||||||
|
) -> Result<(u16, Option<Element>)> {
|
||||||
|
let url = format!(
|
||||||
|
"{}?Cmd={cmd}&User={}&DeviceId={}&DeviceType={}{extra_q}",
|
||||||
|
self.url,
|
||||||
|
url::form_urlencoded::byte_serialize(self.user.as_bytes()).collect::<String>(),
|
||||||
|
self.device,
|
||||||
|
url::form_urlencoded::byte_serialize(self.device_type.as_bytes()).collect::<String>()
|
||||||
|
);
|
||||||
|
let mut rb = self
|
||||||
|
.http
|
||||||
|
.post(url)
|
||||||
|
.basic_auth(&self.user, Some(&self.pass))
|
||||||
|
.header("MS-ASProtocolVersion", &self.version)
|
||||||
|
.header("X-MS-PolicyKey", &self.policy_key)
|
||||||
|
.header("User-Agent", "ActivePush-Probe/1.0")
|
||||||
|
.header("Content-Type", "application/vnd.ms-sync.wbxml");
|
||||||
|
if let Some(b) = body {
|
||||||
|
rb = rb.body(wbxml::encode(&b));
|
||||||
|
}
|
||||||
|
let r = rb.send().await.with_context(|| format!("{cmd} request"))?;
|
||||||
|
let st = r.status().as_u16();
|
||||||
|
let bytes = r.bytes().await?;
|
||||||
|
if bytes.is_empty() || st != 200 {
|
||||||
|
return Ok((st, None));
|
||||||
|
}
|
||||||
|
Ok((st, Some(wbxml::decode(&bytes).context("decode response")?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn provision(&mut self) -> Result<String> {
|
||||||
|
let req = Element::new("Provision:Provision")
|
||||||
|
.with(
|
||||||
|
Element::new("Settings:DeviceInformation").with(
|
||||||
|
Element::new("Settings:Set")
|
||||||
|
.with_text("Settings:Model", self.model.clone())
|
||||||
|
.with_text("Settings:FriendlyName", self.model.clone())
|
||||||
|
.with_text("Settings:OS", "ActivePush probe")
|
||||||
|
.with_text("Settings:UserAgent", "ActivePush-Probe/1.0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
Element::new("Provision:Policies").with(
|
||||||
|
Element::new("Provision:Policy")
|
||||||
|
.with_text("Provision:PolicyType", "MS-EAS-Provisioning-WBXML"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let (_, r) = self.cmd("Provision", Some(req)).await?;
|
||||||
|
let r = r.context("empty provision response")?;
|
||||||
|
let pol = r
|
||||||
|
.path(&["Provision:Policies", "Provision:Policy"])
|
||||||
|
.context("no policy")?;
|
||||||
|
if pol.child_text("Provision:Status").as_deref() == Some("2") {
|
||||||
|
return Ok("provisioning disabled on server".into());
|
||||||
|
}
|
||||||
|
let temp = pol
|
||||||
|
.child_text("Provision:PolicyKey")
|
||||||
|
.context("no temp key")?;
|
||||||
|
self.policy_key = temp.clone();
|
||||||
|
let ack = Element::new("Provision:Provision").with(
|
||||||
|
Element::new("Provision:Policies").with(
|
||||||
|
Element::new("Provision:Policy")
|
||||||
|
.with_text("Provision:PolicyType", "MS-EAS-Provisioning-WBXML")
|
||||||
|
.with_text("Provision:PolicyKey", temp)
|
||||||
|
.with_text("Provision:Status", "1"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let (_, r) = self.cmd("Provision", Some(ack)).await?;
|
||||||
|
let r = r.context("empty provision ack")?;
|
||||||
|
let key = r
|
||||||
|
.path(&["Provision:Policies", "Provision:Policy"])
|
||||||
|
.and_then(|p| p.child_text("Provision:PolicyKey"))
|
||||||
|
.context("no final key")?;
|
||||||
|
self.policy_key = key.clone();
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn folder_sync(&self, key: &str) -> Result<Element> {
|
||||||
|
let (st, r) = self
|
||||||
|
.cmd(
|
||||||
|
"FolderSync",
|
||||||
|
Some(
|
||||||
|
Element::new("FolderHierarchy:FolderSync")
|
||||||
|
.with_text("FolderHierarchy:SyncKey", key),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
r.with_context(|| format!("FolderSync returned HTTP {st}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync(
|
||||||
|
&self,
|
||||||
|
collection: &str,
|
||||||
|
key: &str,
|
||||||
|
window: u32,
|
||||||
|
commands: Option<Element>,
|
||||||
|
) -> Result<Element> {
|
||||||
|
let mut col = Element::new("AirSync:Collection")
|
||||||
|
.with_text("AirSync:SyncKey", key)
|
||||||
|
.with_text("AirSync:CollectionId", collection);
|
||||||
|
if key != "0" {
|
||||||
|
col.push(Element::new("AirSync:DeletesAsMoves"));
|
||||||
|
col.push(Element::new("AirSync:GetChanges"));
|
||||||
|
col.push_text("AirSync:WindowSize", window.to_string());
|
||||||
|
col.push(
|
||||||
|
Element::new("AirSync:Options").with(
|
||||||
|
Element::new("AirSyncBase:BodyPreference")
|
||||||
|
.with_text("AirSyncBase:Type", "1")
|
||||||
|
.with_text("AirSyncBase:TruncationSize", "2048"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(c) = commands {
|
||||||
|
col.push(c);
|
||||||
|
}
|
||||||
|
let req = Element::new("AirSync:Sync").with(Element::new("AirSync:Collections").with(col));
|
||||||
|
let (st, r) = self.cmd("Sync", Some(req)).await?;
|
||||||
|
r.with_context(|| format!("Sync returned HTTP {st} without body"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a read-only smoke test and print a report.
|
||||||
|
pub async fn run(
|
||||||
|
url: &str,
|
||||||
|
user: &str,
|
||||||
|
pass: &str,
|
||||||
|
insecure: bool,
|
||||||
|
device_id: &str,
|
||||||
|
device_type: &str,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut p = Probe::new(url, user, pass, device_id, insecure)?;
|
||||||
|
p.device_type = device_type.to_string();
|
||||||
|
p.model = model.to_string();
|
||||||
|
let (st, vers, _) = p.options().await?;
|
||||||
|
println!("OPTIONS HTTP {st} versions: {vers}");
|
||||||
|
if st == 401 {
|
||||||
|
bail!("authentication failed");
|
||||||
|
}
|
||||||
|
match p.provision().await {
|
||||||
|
Ok(k) => println!("Provision policy key {k}"),
|
||||||
|
Err(e) => println!("Provision failed: {e:#}"),
|
||||||
|
}
|
||||||
|
let fs = p.folder_sync("0").await?;
|
||||||
|
let status = fs.child_text("FolderHierarchy:Status").unwrap_or_default();
|
||||||
|
let mut inbox = None;
|
||||||
|
let mut n = 0;
|
||||||
|
if let Some(ch) = fs.child("FolderHierarchy:Changes") {
|
||||||
|
for a in ch.children_named("FolderHierarchy:Add") {
|
||||||
|
n += 1;
|
||||||
|
let t = a.child_text("FolderHierarchy:Type").unwrap_or_default();
|
||||||
|
let name = a
|
||||||
|
.child_text("FolderHierarchy:DisplayName")
|
||||||
|
.unwrap_or_default();
|
||||||
|
let id = a.child_text("FolderHierarchy:ServerId").unwrap_or_default();
|
||||||
|
println!(" folder {id:>4} type {t:>2} {name}");
|
||||||
|
if t == "2" {
|
||||||
|
inbox = Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("FolderSync status {status}, {n} folders");
|
||||||
|
if let Some(ib) = inbox {
|
||||||
|
let s0 = p.sync(&ib, "0", 0, None).await?;
|
||||||
|
let key = s0
|
||||||
|
.path(&["AirSync:Collections", "AirSync:Collection"])
|
||||||
|
.and_then(|c| c.child_text("AirSync:SyncKey"))
|
||||||
|
.context("no sync key")?;
|
||||||
|
let s1 = p.sync(&ib, &key, 5, None).await?;
|
||||||
|
let col = s1
|
||||||
|
.path(&["AirSync:Collections", "AirSync:Collection"])
|
||||||
|
.context("no collection")?;
|
||||||
|
let adds = col
|
||||||
|
.child("AirSync:Commands")
|
||||||
|
.map(|c| c.children.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
println!(
|
||||||
|
"Sync Inbox status {}, {adds} item(s){}",
|
||||||
|
col.child_text("AirSync:Status").unwrap_or_default(),
|
||||||
|
if col.has("AirSync:MoreAvailable") {
|
||||||
|
", more available"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if let Some(first) = col
|
||||||
|
.child("AirSync:Commands")
|
||||||
|
.and_then(|c| c.children.first())
|
||||||
|
{
|
||||||
|
if let Some(app) = first.child("AirSync:ApplicationData") {
|
||||||
|
println!(
|
||||||
|
" newest: {} from {}",
|
||||||
|
app.child_text("Email:Subject").unwrap_or_default(),
|
||||||
|
app.child_text("Email:From").unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("OK");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! HTTP(S) listeners with graceful shutdown.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::extract::ConnectInfo;
|
||||||
|
use axum::Router;
|
||||||
|
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||||
|
use hyper_util::server::conn::auto::Builder;
|
||||||
|
use hyper_util::service::TowerToHyperService;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::task::JoinSet;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
pub async fn serve(
|
||||||
|
name: &'static str,
|
||||||
|
listener: TcpListener,
|
||||||
|
router: Router,
|
||||||
|
tls: Option<Arc<rustls::ServerConfig>>,
|
||||||
|
shutdown: CancellationToken,
|
||||||
|
) {
|
||||||
|
let acceptor = tls.map(tokio_rustls::TlsAcceptor::from);
|
||||||
|
let mut conns = JoinSet::new();
|
||||||
|
loop {
|
||||||
|
let (stream, peer) = tokio::select! {
|
||||||
|
r = listener.accept() => match r {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("{name}: accept failed: {e}");
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ = shutdown.cancelled() => break,
|
||||||
|
};
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
let svc = router
|
||||||
|
.clone()
|
||||||
|
.layer(axum::Extension(ConnectInfo::<SocketAddr>(peer)));
|
||||||
|
let acceptor = acceptor.clone();
|
||||||
|
let stop = shutdown.clone();
|
||||||
|
conns.spawn(async move {
|
||||||
|
let hyper_svc = TowerToHyperService::new(svc);
|
||||||
|
let mut builder = Builder::new(TokioExecutor::new());
|
||||||
|
builder
|
||||||
|
.http1()
|
||||||
|
.timer(hyper_util::rt::TokioTimer::new())
|
||||||
|
.header_read_timeout(Duration::from_secs(30))
|
||||||
|
.max_buf_size(256 * 1024);
|
||||||
|
builder.http2().max_concurrent_streams(64);
|
||||||
|
match acceptor {
|
||||||
|
Some(a) => {
|
||||||
|
let tls = match tokio::time::timeout(Duration::from_secs(15), a.accept(stream))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(s)) => s,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let conn = builder.serve_connection_with_upgrades(TokioIo::new(tls), hyper_svc);
|
||||||
|
tokio::pin!(conn);
|
||||||
|
tokio::select! {
|
||||||
|
_ = conn.as_mut() => {}
|
||||||
|
_ = stop.cancelled() => {
|
||||||
|
conn.as_mut().graceful_shutdown();
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(10), conn).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let conn =
|
||||||
|
builder.serve_connection_with_upgrades(TokioIo::new(stream), hyper_svc);
|
||||||
|
tokio::pin!(conn);
|
||||||
|
tokio::select! {
|
||||||
|
_ = conn.as_mut() => {}
|
||||||
|
_ = stop.cancelled() => {
|
||||||
|
conn.as_mut().graceful_shutdown();
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(10), conn).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// reap finished connections
|
||||||
|
while let Some(r) = conns.try_join_next() {
|
||||||
|
let _ = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"{name}: stopping, waiting for {} connection(s)",
|
||||||
|
conns.len()
|
||||||
|
);
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(12), async {
|
||||||
|
while conns.join_next().await.is_some() {}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//! Shared rustls client configuration (system trust store + optional insecure mode).
|
||||||
|
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
|
||||||
|
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||||
|
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||||
|
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme};
|
||||||
|
|
||||||
|
fn provider() -> Arc<rustls::crypto::CryptoProvider> {
|
||||||
|
Arc::new(rustls::crypto::ring::default_provider())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roots() -> RootCertStore {
|
||||||
|
let mut store = RootCertStore::empty();
|
||||||
|
let native = rustls_native_certs::load_native_certs();
|
||||||
|
for cert in native.certs {
|
||||||
|
let _ = store.add(cert);
|
||||||
|
}
|
||||||
|
if store.is_empty() {
|
||||||
|
store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||||
|
}
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client_config(verify: bool) -> Arc<ClientConfig> {
|
||||||
|
static VERIFIED: OnceLock<Arc<ClientConfig>> = OnceLock::new();
|
||||||
|
static INSECURE: OnceLock<Arc<ClientConfig>> = OnceLock::new();
|
||||||
|
if verify {
|
||||||
|
VERIFIED
|
||||||
|
.get_or_init(|| {
|
||||||
|
Arc::new(
|
||||||
|
ClientConfig::builder_with_provider(provider())
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.expect("tls versions")
|
||||||
|
.with_root_certificates(roots())
|
||||||
|
.with_no_client_auth(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
} else {
|
||||||
|
INSECURE
|
||||||
|
.get_or_init(|| {
|
||||||
|
Arc::new(
|
||||||
|
ClientConfig::builder_with_provider(provider())
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.expect("tls versions")
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(NoVerify))
|
||||||
|
.with_no_client_auth(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts any certificate. Only used when the administrator explicitly disables
|
||||||
|
/// verification for a backend (e.g. self-signed certificate on localhost).
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct NoVerify;
|
||||||
|
|
||||||
|
impl ServerCertVerifier for NoVerify {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
_end_entity: &CertificateDer<'_>,
|
||||||
|
_intermediates: &[CertificateDer<'_>],
|
||||||
|
_server_name: &ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: UnixTime,
|
||||||
|
) -> Result<ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &CertificateDer<'_>,
|
||||||
|
_dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &CertificateDer<'_>,
|
||||||
|
_dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a PEM certificate chain + key for the optional built-in HTTPS listeners.
|
||||||
|
pub fn server_config(cert_path: &str, key_path: &str) -> anyhow::Result<Arc<rustls::ServerConfig>> {
|
||||||
|
use anyhow::Context;
|
||||||
|
let certs = rustls_pemfile::certs(&mut std::io::BufReader::new(
|
||||||
|
std::fs::File::open(cert_path).with_context(|| format!("open {cert_path}"))?,
|
||||||
|
))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let key = rustls_pemfile::private_key(&mut std::io::BufReader::new(
|
||||||
|
std::fs::File::open(key_path).with_context(|| format!("open {key_path}"))?,
|
||||||
|
))?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no private key in {key_path}"))?;
|
||||||
|
let mut cfg = rustls::ServerConfig::builder_with_provider(provider())
|
||||||
|
.with_safe_default_protocol_versions()?
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(certs, key)?;
|
||||||
|
cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
||||||
|
Ok(Arc::new(cfg))
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
pub fn now() -> i64 {
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn random_token(bytes: usize) -> String {
|
||||||
|
let mut b = vec![0u8; bytes];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut b);
|
||||||
|
hex::encode(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
hex::encode(Sha256::digest(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
a.len() == b.len() && bool::from(a.ct_eq(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Cidr {
|
||||||
|
addr: IpAddr,
|
||||||
|
prefix: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cidr {
|
||||||
|
pub fn parse(s: &str) -> Option<Cidr> {
|
||||||
|
let s = s.trim();
|
||||||
|
let (a, p) = match s.split_once('/') {
|
||||||
|
Some((a, p)) => (a, Some(p)),
|
||||||
|
None => (s, None),
|
||||||
|
};
|
||||||
|
let addr: IpAddr = a.parse().ok()?;
|
||||||
|
let max = if addr.is_ipv4() { 32 } else { 128 };
|
||||||
|
let prefix = match p {
|
||||||
|
Some(p) => p.parse().ok().filter(|x: &u8| *x <= max)?,
|
||||||
|
None => max,
|
||||||
|
};
|
||||||
|
Some(Cidr { addr, prefix })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains(&self, ip: &IpAddr) -> bool {
|
||||||
|
let ip = normalize(*ip);
|
||||||
|
match (self.addr, ip) {
|
||||||
|
(IpAddr::V4(n), IpAddr::V4(i)) => {
|
||||||
|
let mask = if self.prefix == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
u32::MAX << (32 - self.prefix)
|
||||||
|
};
|
||||||
|
(u32::from(n) & mask) == (u32::from(i) & mask)
|
||||||
|
}
|
||||||
|
(IpAddr::V6(n), IpAddr::V6(i)) => {
|
||||||
|
let mask = if self.prefix == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
u128::MAX << (128 - self.prefix)
|
||||||
|
};
|
||||||
|
(u128::from(n) & mask) == (u128::from(i) & mask)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize(ip: IpAddr) -> IpAddr {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip),
|
||||||
|
v4 => v4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ip_in_any(ip: &IpAddr, nets: &[String]) -> bool {
|
||||||
|
nets.iter()
|
||||||
|
.filter_map(|n| Cidr::parse(n))
|
||||||
|
.any(|c| c.contains(ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn html_escape(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'&' => out.push_str("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'"' => out.push_str("""),
|
||||||
|
'\'' => out.push_str("'"),
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn xml_escape(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'&' => out.push_str("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'"' => out.push_str("""),
|
||||||
|
'\'' => out.push_str("'"),
|
||||||
|
c if (c as u32) < 0x20 && c != '\n' && c != '\r' && c != '\t' => {}
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate a string to at most `n` bytes on a char boundary.
|
||||||
|
pub fn truncate_bytes(s: &str, n: usize) -> &str {
|
||||||
|
if s.len() <= n {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
let mut i = n;
|
||||||
|
while i > 0 && !s.is_char_boundary(i) {
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
&s[..i]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert HTML to readable plain text (used when a device wants text but only HTML exists).
|
||||||
|
pub fn html_to_text(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len() / 2);
|
||||||
|
let lower = html.to_ascii_lowercase();
|
||||||
|
let bytes = html.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut skip_until: Option<&str> = None;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if let Some(end) = skip_until {
|
||||||
|
if lower[i..].starts_with(end) {
|
||||||
|
skip_until = None;
|
||||||
|
i += end.len();
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if bytes[i] == b'<' {
|
||||||
|
let close = match html[i..].find('>') {
|
||||||
|
Some(c) => i + c,
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
let tag = lower[i + 1..close].trim_start_matches('/');
|
||||||
|
let name: String = tag
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_alphanumeric())
|
||||||
|
.collect();
|
||||||
|
match name.as_str() {
|
||||||
|
"script" => skip_until = Some("</script>"),
|
||||||
|
"style" => skip_until = Some("</style>"),
|
||||||
|
"head" => skip_until = Some("</head>"),
|
||||||
|
"br" => out.push('\n'),
|
||||||
|
"p" | "div" | "tr" | "li" | "h1" | "h2" | "h3" | "h4" | "blockquote" | "table" => {
|
||||||
|
if !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
if name == "li" && !lower[i + 1..].starts_with('/') {
|
||||||
|
out.push_str("• ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"td" | "th" => out.push('\t'),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i = close + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if bytes[i] == b'&' {
|
||||||
|
if let Some(semi) = html[i..].find(';').filter(|s| *s < 10) {
|
||||||
|
let ent = &html[i + 1..i + semi];
|
||||||
|
let rep = match ent {
|
||||||
|
"nbsp" => Some(' '),
|
||||||
|
"amp" => Some('&'),
|
||||||
|
"lt" => Some('<'),
|
||||||
|
"gt" => Some('>'),
|
||||||
|
"quot" => Some('"'),
|
||||||
|
"apos" | "#39" => Some('\''),
|
||||||
|
e if e.starts_with("#x") => u32::from_str_radix(&e[2..], 16)
|
||||||
|
.ok()
|
||||||
|
.and_then(char::from_u32),
|
||||||
|
e if e.starts_with('#') => e[1..].parse().ok().and_then(char::from_u32),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(c) = rep {
|
||||||
|
out.push(c);
|
||||||
|
i += semi + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ch = html[i..].chars().next().unwrap_or(' ');
|
||||||
|
if ch == '\r' || ch == '\n' || ch == '\t' {
|
||||||
|
if !out.ends_with(' ') && !out.ends_with('\n') {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
i += ch.len_utf8();
|
||||||
|
}
|
||||||
|
// collapse 3+ newlines
|
||||||
|
let mut res = String::with_capacity(out.len());
|
||||||
|
let mut nl = 0;
|
||||||
|
for c in out.chars() {
|
||||||
|
if c == '\n' {
|
||||||
|
nl += 1;
|
||||||
|
if nl > 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nl = 0;
|
||||||
|
}
|
||||||
|
res.push(c);
|
||||||
|
}
|
||||||
|
res.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text_to_html(text: &str) -> String {
|
||||||
|
let mut out = String::from("<html><head><meta charset=\"utf-8\"></head><body><div style=\"white-space:pre-wrap;font-family:sans-serif\">");
|
||||||
|
out.push_str(&html_escape(text));
|
||||||
|
out.push_str("</div></body></html>");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a login name received from a device.
|
||||||
|
pub fn valid_login(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.len() <= 254
|
||||||
|
&& s.chars().all(|c| {
|
||||||
|
!c.is_control()
|
||||||
|
&& !c.is_whitespace()
|
||||||
|
&& !matches!(c, '/' | '\\' | '"' | '\'' | '<' | '>' | '%' | '*' | '?')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_ts(ts: i64) -> String {
|
||||||
|
chrono::DateTime::from_timestamp(ts, 0)
|
||||||
|
.map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cidr() {
|
||||||
|
let c = Cidr::parse("10.0.0.0/8").unwrap();
|
||||||
|
assert!(c.contains(&"10.2.3.4".parse().unwrap()));
|
||||||
|
assert!(!c.contains(&"11.2.3.4".parse().unwrap()));
|
||||||
|
let l = Cidr::parse("127.0.0.1").unwrap();
|
||||||
|
assert!(l.contains(&"::ffff:127.0.0.1".parse().unwrap()));
|
||||||
|
assert!(Cidr::parse("::1/128")
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"::1".parse().unwrap()));
|
||||||
|
assert!(Cidr::parse("1.2.3.4/33").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html2text() {
|
||||||
|
let t = html_to_text("<html><head><style>x{}</style></head><body><p>Hello <b>World</b></p><br>a & b</body></html>");
|
||||||
|
assert_eq!(t, "Hello World\n\na & b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logins() {
|
||||||
|
assert!(valid_login("jan.kowalski@firma.pl"));
|
||||||
|
assert!(!valid_login("a b"));
|
||||||
|
assert!(!valid_login("../etc"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
// Generated from Z-Push wbxmldefs.php with MS-ASWBXML standard tag names.
|
||||||
|
pub const NAMESPACES: &[&str] = &[
|
||||||
|
"AirSync",
|
||||||
|
"Contacts",
|
||||||
|
"Email",
|
||||||
|
"AirNotify",
|
||||||
|
"Calendar",
|
||||||
|
"Move",
|
||||||
|
"GetItemEstimate",
|
||||||
|
"FolderHierarchy",
|
||||||
|
"MeetingResponse",
|
||||||
|
"Tasks",
|
||||||
|
"ResolveRecipients",
|
||||||
|
"ValidateCert",
|
||||||
|
"Contacts2",
|
||||||
|
"Ping",
|
||||||
|
"Provision",
|
||||||
|
"Search",
|
||||||
|
"GAL",
|
||||||
|
"AirSyncBase",
|
||||||
|
"Settings",
|
||||||
|
"DocumentLibrary",
|
||||||
|
"ItemOperations",
|
||||||
|
"ComposeMail",
|
||||||
|
"Email2",
|
||||||
|
"Notes",
|
||||||
|
"RightsManagement",
|
||||||
|
"Find",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub const PAGES: &[&[(u8, &str)]] = &[
|
||||||
|
// 0 AirSync
|
||||||
|
&[
|
||||||
|
(0x05, "Sync"),
|
||||||
|
(0x06, "Responses"),
|
||||||
|
(0x07, "Add"),
|
||||||
|
(0x08, "Change"),
|
||||||
|
(0x09, "Delete"),
|
||||||
|
(0x0A, "Fetch"),
|
||||||
|
(0x0B, "SyncKey"),
|
||||||
|
(0x0C, "ClientId"),
|
||||||
|
(0x0D, "ServerId"),
|
||||||
|
(0x0E, "Status"),
|
||||||
|
(0x0F, "Collection"),
|
||||||
|
(0x10, "Class"),
|
||||||
|
(0x11, "Version"),
|
||||||
|
(0x12, "CollectionId"),
|
||||||
|
(0x13, "GetChanges"),
|
||||||
|
(0x14, "MoreAvailable"),
|
||||||
|
(0x15, "WindowSize"),
|
||||||
|
(0x16, "Commands"),
|
||||||
|
(0x17, "Options"),
|
||||||
|
(0x18, "FilterType"),
|
||||||
|
(0x19, "Truncation"),
|
||||||
|
(0x1A, "RTFTruncation"),
|
||||||
|
(0x1B, "Conflict"),
|
||||||
|
(0x1C, "Collections"),
|
||||||
|
(0x1D, "ApplicationData"),
|
||||||
|
(0x1E, "DeletesAsMoves"),
|
||||||
|
(0x1F, "NotifyGUID"),
|
||||||
|
(0x20, "Supported"),
|
||||||
|
(0x21, "SoftDelete"),
|
||||||
|
(0x22, "MIMESupport"),
|
||||||
|
(0x23, "MIMETruncation"),
|
||||||
|
(0x24, "Wait"),
|
||||||
|
(0x25, "Limit"),
|
||||||
|
(0x26, "Partial"),
|
||||||
|
(0x27, "ConversationMode"),
|
||||||
|
(0x28, "MaxItems"),
|
||||||
|
(0x29, "HeartbeatInterval"),
|
||||||
|
],
|
||||||
|
// 1 Contacts
|
||||||
|
&[
|
||||||
|
(0x05, "Anniversary"),
|
||||||
|
(0x06, "AssistantName"),
|
||||||
|
(0x07, "AssistantPhoneNumber"),
|
||||||
|
(0x08, "Birthday"),
|
||||||
|
(0x09, "Body"),
|
||||||
|
(0x0A, "BodySize"),
|
||||||
|
(0x0B, "BodyTruncated"),
|
||||||
|
(0x0C, "Business2PhoneNumber"),
|
||||||
|
(0x0D, "BusinessCity"),
|
||||||
|
(0x0E, "BusinessCountry"),
|
||||||
|
(0x0F, "BusinessPostalCode"),
|
||||||
|
(0x10, "BusinessState"),
|
||||||
|
(0x11, "BusinessStreet"),
|
||||||
|
(0x12, "BusinessFaxNumber"),
|
||||||
|
(0x13, "BusinessPhoneNumber"),
|
||||||
|
(0x14, "CarPhoneNumber"),
|
||||||
|
(0x15, "Categories"),
|
||||||
|
(0x16, "Category"),
|
||||||
|
(0x17, "Children"),
|
||||||
|
(0x18, "Child"),
|
||||||
|
(0x19, "CompanyName"),
|
||||||
|
(0x1A, "Department"),
|
||||||
|
(0x1B, "Email1Address"),
|
||||||
|
(0x1C, "Email2Address"),
|
||||||
|
(0x1D, "Email3Address"),
|
||||||
|
(0x1E, "FileAs"),
|
||||||
|
(0x1F, "FirstName"),
|
||||||
|
(0x20, "Home2PhoneNumber"),
|
||||||
|
(0x21, "HomeCity"),
|
||||||
|
(0x22, "HomeCountry"),
|
||||||
|
(0x23, "HomePostalCode"),
|
||||||
|
(0x24, "HomeState"),
|
||||||
|
(0x25, "HomeStreet"),
|
||||||
|
(0x26, "HomeFaxNumber"),
|
||||||
|
(0x27, "HomePhoneNumber"),
|
||||||
|
(0x28, "JobTitle"),
|
||||||
|
(0x29, "LastName"),
|
||||||
|
(0x2A, "MiddleName"),
|
||||||
|
(0x2B, "MobilePhoneNumber"),
|
||||||
|
(0x2C, "OfficeLocation"),
|
||||||
|
(0x2D, "OtherCity"),
|
||||||
|
(0x2E, "OtherCountry"),
|
||||||
|
(0x2F, "OtherPostalCode"),
|
||||||
|
(0x30, "OtherState"),
|
||||||
|
(0x31, "OtherStreet"),
|
||||||
|
(0x32, "PagerNumber"),
|
||||||
|
(0x33, "RadioPhoneNumber"),
|
||||||
|
(0x34, "Spouse"),
|
||||||
|
(0x35, "Suffix"),
|
||||||
|
(0x36, "Title"),
|
||||||
|
(0x37, "WebPage"),
|
||||||
|
(0x38, "YomiCompanyName"),
|
||||||
|
(0x39, "YomiFirstName"),
|
||||||
|
(0x3A, "YomiLastName"),
|
||||||
|
(0x3B, "Rtf"),
|
||||||
|
(0x3C, "Picture"),
|
||||||
|
(0x3D, "Alias"),
|
||||||
|
(0x3E, "WeightedRank"),
|
||||||
|
],
|
||||||
|
// 2 Email
|
||||||
|
&[
|
||||||
|
(0x05, "Attachment"),
|
||||||
|
(0x06, "Attachments"),
|
||||||
|
(0x07, "AttName"),
|
||||||
|
(0x08, "AttSize"),
|
||||||
|
(0x09, "AttOid"),
|
||||||
|
(0x0A, "AttMethod"),
|
||||||
|
(0x0B, "AttRemoved"),
|
||||||
|
(0x0C, "Body"),
|
||||||
|
(0x0D, "BodySize"),
|
||||||
|
(0x0E, "BodyTruncated"),
|
||||||
|
(0x0F, "DateReceived"),
|
||||||
|
(0x10, "DisplayName"),
|
||||||
|
(0x11, "DisplayTo"),
|
||||||
|
(0x12, "Importance"),
|
||||||
|
(0x13, "MessageClass"),
|
||||||
|
(0x14, "Subject"),
|
||||||
|
(0x15, "Read"),
|
||||||
|
(0x16, "To"),
|
||||||
|
(0x17, "Cc"),
|
||||||
|
(0x18, "From"),
|
||||||
|
(0x19, "ReplyTo"),
|
||||||
|
(0x1A, "AllDayEvent"),
|
||||||
|
(0x1B, "Categories"),
|
||||||
|
(0x1C, "Category"),
|
||||||
|
(0x1D, "DtStamp"),
|
||||||
|
(0x1E, "EndTime"),
|
||||||
|
(0x1F, "InstanceType"),
|
||||||
|
(0x20, "BusyStatus"),
|
||||||
|
(0x21, "Location"),
|
||||||
|
(0x22, "MeetingRequest"),
|
||||||
|
(0x23, "Organizer"),
|
||||||
|
(0x24, "RecurrenceId"),
|
||||||
|
(0x25, "Reminder"),
|
||||||
|
(0x26, "ResponseRequested"),
|
||||||
|
(0x27, "Recurrences"),
|
||||||
|
(0x28, "Recurrence"),
|
||||||
|
(0x29, "Type"),
|
||||||
|
(0x2A, "Until"),
|
||||||
|
(0x2B, "Occurrences"),
|
||||||
|
(0x2C, "Interval"),
|
||||||
|
(0x2D, "DayOfWeek"),
|
||||||
|
(0x2E, "DayOfMonth"),
|
||||||
|
(0x2F, "WeekOfMonth"),
|
||||||
|
(0x30, "MonthOfYear"),
|
||||||
|
(0x31, "StartTime"),
|
||||||
|
(0x32, "Sensitivity"),
|
||||||
|
(0x33, "TimeZone"),
|
||||||
|
(0x34, "GlobalObjId"),
|
||||||
|
(0x35, "ThreadTopic"),
|
||||||
|
(0x36, "MIMEData"),
|
||||||
|
(0x37, "MIMETruncated"),
|
||||||
|
(0x38, "MIMESize"),
|
||||||
|
(0x39, "InternetCPID"),
|
||||||
|
(0x3A, "Flag"),
|
||||||
|
(0x3B, "FlagStatus"),
|
||||||
|
(0x3C, "ContentClass"),
|
||||||
|
(0x3D, "FlagType"),
|
||||||
|
(0x3E, "CompleteTime"),
|
||||||
|
(0x3F, "DisallowNewTimeProposal"),
|
||||||
|
],
|
||||||
|
// 3 AirNotify
|
||||||
|
&[
|
||||||
|
(0x05, "Notify"),
|
||||||
|
(0x06, "Notification"),
|
||||||
|
(0x07, "Version"),
|
||||||
|
(0x08, "Lifetime"),
|
||||||
|
(0x09, "DeviceInfo"),
|
||||||
|
(0x0A, "Enable"),
|
||||||
|
(0x0B, "Folder"),
|
||||||
|
(0x0C, "ServerEntryId"),
|
||||||
|
(0x0D, "DeviceAddress"),
|
||||||
|
(0x0E, "ValidCarrierProfiles"),
|
||||||
|
(0x0F, "CarrierProfile"),
|
||||||
|
(0x10, "Status"),
|
||||||
|
(0x11, "Replies"),
|
||||||
|
(0x12, "Devices"),
|
||||||
|
(0x13, "Device"),
|
||||||
|
(0x14, "Id"),
|
||||||
|
(0x15, "Expiry"),
|
||||||
|
(0x16, "NotifyGUID"),
|
||||||
|
],
|
||||||
|
// 4 Calendar
|
||||||
|
&[
|
||||||
|
(0x05, "TimeZone"),
|
||||||
|
(0x06, "AllDayEvent"),
|
||||||
|
(0x07, "Attendees"),
|
||||||
|
(0x08, "Attendee"),
|
||||||
|
(0x09, "Email"),
|
||||||
|
(0x0A, "Name"),
|
||||||
|
(0x0B, "Body"),
|
||||||
|
(0x0C, "BodyTruncated"),
|
||||||
|
(0x0D, "BusyStatus"),
|
||||||
|
(0x0E, "Categories"),
|
||||||
|
(0x0F, "Category"),
|
||||||
|
(0x10, "Rtf"),
|
||||||
|
(0x11, "DtStamp"),
|
||||||
|
(0x12, "EndTime"),
|
||||||
|
(0x13, "Exception"),
|
||||||
|
(0x14, "Exceptions"),
|
||||||
|
(0x15, "Deleted"),
|
||||||
|
(0x16, "ExceptionStartTime"),
|
||||||
|
(0x17, "Location"),
|
||||||
|
(0x18, "MeetingStatus"),
|
||||||
|
(0x19, "OrganizerEmail"),
|
||||||
|
(0x1A, "OrganizerName"),
|
||||||
|
(0x1B, "Recurrence"),
|
||||||
|
(0x1C, "Type"),
|
||||||
|
(0x1D, "Until"),
|
||||||
|
(0x1E, "Occurrences"),
|
||||||
|
(0x1F, "Interval"),
|
||||||
|
(0x20, "DayOfWeek"),
|
||||||
|
(0x21, "DayOfMonth"),
|
||||||
|
(0x22, "WeekOfMonth"),
|
||||||
|
(0x23, "MonthOfYear"),
|
||||||
|
(0x24, "Reminder"),
|
||||||
|
(0x25, "Sensitivity"),
|
||||||
|
(0x26, "Subject"),
|
||||||
|
(0x27, "StartTime"),
|
||||||
|
(0x28, "UID"),
|
||||||
|
(0x29, "AttendeeStatus"),
|
||||||
|
(0x2A, "AttendeeType"),
|
||||||
|
(0x2B, "Attachment"),
|
||||||
|
(0x2C, "Attachments"),
|
||||||
|
(0x2D, "AttName"),
|
||||||
|
(0x2E, "AttSize"),
|
||||||
|
(0x2F, "AttOid"),
|
||||||
|
(0x30, "AttMethod"),
|
||||||
|
(0x31, "AttRemoved"),
|
||||||
|
(0x32, "DisplayName"),
|
||||||
|
(0x33, "DisallowNewTimeProposal"),
|
||||||
|
(0x34, "ResponseRequested"),
|
||||||
|
(0x35, "AppointmentReplyTime"),
|
||||||
|
(0x36, "ResponseType"),
|
||||||
|
(0x37, "CalendarType"),
|
||||||
|
(0x38, "IsLeapMonth"),
|
||||||
|
(0x39, "FirstDayOfWeek"),
|
||||||
|
(0x3A, "OnlineMeetingConfLink"),
|
||||||
|
(0x3B, "OnlineMeetingExternalLink"),
|
||||||
|
(0x3C, "ClientUid"),
|
||||||
|
],
|
||||||
|
// 5 Move
|
||||||
|
&[
|
||||||
|
(0x05, "MoveItems"),
|
||||||
|
(0x06, "Move"),
|
||||||
|
(0x07, "SrcMsgId"),
|
||||||
|
(0x08, "SrcFldId"),
|
||||||
|
(0x09, "DstFldId"),
|
||||||
|
(0x0A, "Response"),
|
||||||
|
(0x0B, "Status"),
|
||||||
|
(0x0C, "DstMsgId"),
|
||||||
|
],
|
||||||
|
// 6 GetItemEstimate
|
||||||
|
&[
|
||||||
|
(0x05, "GetItemEstimate"),
|
||||||
|
(0x06, "Version"),
|
||||||
|
(0x07, "Collections"),
|
||||||
|
(0x08, "Collection"),
|
||||||
|
(0x09, "Class"),
|
||||||
|
(0x0A, "CollectionId"),
|
||||||
|
(0x0B, "DateTime"),
|
||||||
|
(0x0C, "Estimate"),
|
||||||
|
(0x0D, "Response"),
|
||||||
|
(0x0E, "Status"),
|
||||||
|
],
|
||||||
|
// 7 FolderHierarchy
|
||||||
|
&[
|
||||||
|
(0x05, "Folders"),
|
||||||
|
(0x06, "Folder"),
|
||||||
|
(0x07, "DisplayName"),
|
||||||
|
(0x08, "ServerId"),
|
||||||
|
(0x09, "ParentId"),
|
||||||
|
(0x0A, "Type"),
|
||||||
|
(0x0B, "Response"),
|
||||||
|
(0x0C, "Status"),
|
||||||
|
(0x0D, "ContentClass"),
|
||||||
|
(0x0E, "Changes"),
|
||||||
|
(0x0F, "Add"),
|
||||||
|
(0x10, "Delete"),
|
||||||
|
(0x11, "Update"),
|
||||||
|
(0x12, "SyncKey"),
|
||||||
|
(0x13, "FolderCreate"),
|
||||||
|
(0x14, "FolderDelete"),
|
||||||
|
(0x15, "FolderUpdate"),
|
||||||
|
(0x16, "FolderSync"),
|
||||||
|
(0x17, "Count"),
|
||||||
|
(0x18, "Version"),
|
||||||
|
],
|
||||||
|
// 8 MeetingResponse
|
||||||
|
&[
|
||||||
|
(0x05, "CalendarId"),
|
||||||
|
(0x06, "CollectionId"),
|
||||||
|
(0x07, "MeetingResponse"),
|
||||||
|
(0x08, "RequestId"),
|
||||||
|
(0x09, "Request"),
|
||||||
|
(0x0A, "Result"),
|
||||||
|
(0x0B, "Status"),
|
||||||
|
(0x0C, "UserResponse"),
|
||||||
|
(0x0D, "Version"),
|
||||||
|
(0x0E, "InstanceId"),
|
||||||
|
(0x10, "ProposedStartTime"),
|
||||||
|
(0x11, "ProposedEndTime"),
|
||||||
|
(0x12, "SendResponse"),
|
||||||
|
],
|
||||||
|
// 9 Tasks
|
||||||
|
&[
|
||||||
|
(0x05, "Body"),
|
||||||
|
(0x06, "BodySize"),
|
||||||
|
(0x07, "BodyTruncated"),
|
||||||
|
(0x08, "Categories"),
|
||||||
|
(0x09, "Category"),
|
||||||
|
(0x0A, "Complete"),
|
||||||
|
(0x0B, "DateCompleted"),
|
||||||
|
(0x0C, "DueDate"),
|
||||||
|
(0x0D, "UtcDueDate"),
|
||||||
|
(0x0E, "Importance"),
|
||||||
|
(0x0F, "Recurrence"),
|
||||||
|
(0x10, "Type"),
|
||||||
|
(0x11, "Start"),
|
||||||
|
(0x12, "Until"),
|
||||||
|
(0x13, "Occurrences"),
|
||||||
|
(0x14, "Interval"),
|
||||||
|
(0x15, "DayOfMonth"),
|
||||||
|
(0x16, "DayOfWeek"),
|
||||||
|
(0x17, "WeekOfMonth"),
|
||||||
|
(0x18, "MonthOfYear"),
|
||||||
|
(0x19, "Regenerate"),
|
||||||
|
(0x1A, "DeadOccur"),
|
||||||
|
(0x1B, "ReminderSet"),
|
||||||
|
(0x1C, "ReminderTime"),
|
||||||
|
(0x1D, "Sensitivity"),
|
||||||
|
(0x1E, "StartDate"),
|
||||||
|
(0x1F, "UtcStartDate"),
|
||||||
|
(0x20, "Subject"),
|
||||||
|
(0x21, "Rtf"),
|
||||||
|
(0x22, "OrdinalDate"),
|
||||||
|
(0x23, "SubOrdinalDate"),
|
||||||
|
(0x24, "CalendarType"),
|
||||||
|
(0x25, "IsLeapMonth"),
|
||||||
|
(0x26, "FirstDayOfWeek"),
|
||||||
|
],
|
||||||
|
// 10 ResolveRecipients
|
||||||
|
&[
|
||||||
|
(0x05, "ResolveRecipients"),
|
||||||
|
(0x06, "Response"),
|
||||||
|
(0x07, "Status"),
|
||||||
|
(0x08, "Type"),
|
||||||
|
(0x09, "Recipient"),
|
||||||
|
(0x0A, "DisplayName"),
|
||||||
|
(0x0B, "EmailAddress"),
|
||||||
|
(0x0C, "Certificates"),
|
||||||
|
(0x0D, "Certificate"),
|
||||||
|
(0x0E, "MiniCertificate"),
|
||||||
|
(0x0F, "Options"),
|
||||||
|
(0x10, "To"),
|
||||||
|
(0x11, "CertificateRetrieval"),
|
||||||
|
(0x12, "RecipientCount"),
|
||||||
|
(0x13, "MaxCertificates"),
|
||||||
|
(0x14, "MaxAmbiguousRecipients"),
|
||||||
|
(0x15, "CertificateCount"),
|
||||||
|
(0x16, "Availability"),
|
||||||
|
(0x17, "StartTime"),
|
||||||
|
(0x18, "EndTime"),
|
||||||
|
(0x19, "MergedFreeBusy"),
|
||||||
|
(0x1A, "Picture"),
|
||||||
|
(0x1B, "MaxSize"),
|
||||||
|
(0x1C, "Data"),
|
||||||
|
(0x1D, "MaxPictures"),
|
||||||
|
],
|
||||||
|
// 11 ValidateCert
|
||||||
|
&[
|
||||||
|
(0x05, "ValidateCert"),
|
||||||
|
(0x06, "Certificates"),
|
||||||
|
(0x07, "Certificate"),
|
||||||
|
(0x08, "CertificateChain"),
|
||||||
|
(0x09, "CheckCRL"),
|
||||||
|
(0x0A, "Status"),
|
||||||
|
],
|
||||||
|
// 12 Contacts2
|
||||||
|
&[
|
||||||
|
(0x05, "CustomerId"),
|
||||||
|
(0x06, "GovernmentId"),
|
||||||
|
(0x07, "IMAddress"),
|
||||||
|
(0x08, "IMAddress2"),
|
||||||
|
(0x09, "IMAddress3"),
|
||||||
|
(0x0A, "ManagerName"),
|
||||||
|
(0x0B, "CompanyMainPhone"),
|
||||||
|
(0x0C, "AccountName"),
|
||||||
|
(0x0D, "NickName"),
|
||||||
|
(0x0E, "MMS"),
|
||||||
|
],
|
||||||
|
// 13 Ping
|
||||||
|
&[
|
||||||
|
(0x05, "Ping"),
|
||||||
|
(0x06, "AutdState"),
|
||||||
|
(0x07, "Status"),
|
||||||
|
(0x08, "HeartbeatInterval"),
|
||||||
|
(0x09, "Folders"),
|
||||||
|
(0x0A, "Folder"),
|
||||||
|
(0x0B, "Id"),
|
||||||
|
(0x0C, "Class"),
|
||||||
|
(0x0D, "MaxFolders"),
|
||||||
|
(0x0E, "Version"),
|
||||||
|
],
|
||||||
|
// 14 Provision
|
||||||
|
&[
|
||||||
|
(0x05, "Provision"),
|
||||||
|
(0x06, "Policies"),
|
||||||
|
(0x07, "Policy"),
|
||||||
|
(0x08, "PolicyType"),
|
||||||
|
(0x09, "PolicyKey"),
|
||||||
|
(0x0A, "Data"),
|
||||||
|
(0x0B, "Status"),
|
||||||
|
(0x0C, "RemoteWipe"),
|
||||||
|
(0x0D, "EASProvisionDoc"),
|
||||||
|
(0x0E, "DevicePasswordEnabled"),
|
||||||
|
(0x0F, "AlphanumericDevicePasswordRequired"),
|
||||||
|
(0x10, "DeviceEncryptionEnabled"),
|
||||||
|
(0x11, "PasswordRecoveryEnabled"),
|
||||||
|
(0x12, "DocumentBrowseEnabled"),
|
||||||
|
(0x13, "AttachmentsEnabled"),
|
||||||
|
(0x14, "MinDevicePasswordLength"),
|
||||||
|
(0x15, "MaxInactivityTimeDeviceLock"),
|
||||||
|
(0x16, "MaxDevicePasswordFailedAttempts"),
|
||||||
|
(0x17, "MaxAttachmentSize"),
|
||||||
|
(0x18, "AllowSimpleDevicePassword"),
|
||||||
|
(0x19, "DevicePasswordExpiration"),
|
||||||
|
(0x1A, "DevicePasswordHistory"),
|
||||||
|
(0x1B, "AllowStorageCard"),
|
||||||
|
(0x1C, "AllowCamera"),
|
||||||
|
(0x1D, "RequireDeviceEncryption"),
|
||||||
|
(0x1E, "AllowUnsignedApplications"),
|
||||||
|
(0x1F, "AllowUnsignedInstallationPackages"),
|
||||||
|
(0x20, "MinDevicePasswordComplexCharacters"),
|
||||||
|
(0x21, "AllowWiFi"),
|
||||||
|
(0x22, "AllowTextMessaging"),
|
||||||
|
(0x23, "AllowPOPIMAPEmail"),
|
||||||
|
(0x24, "AllowBluetooth"),
|
||||||
|
(0x25, "AllowIrDA"),
|
||||||
|
(0x26, "RequireManualSyncWhenRoaming"),
|
||||||
|
(0x27, "AllowDesktopSync"),
|
||||||
|
(0x28, "MaxCalendarAgeFilter"),
|
||||||
|
(0x29, "AllowHTMLEmail"),
|
||||||
|
(0x2A, "MaxEmailAgeFilter"),
|
||||||
|
(0x2B, "MaxEmailBodyTruncationSize"),
|
||||||
|
(0x2C, "MaxEmailHTMLBodyTruncationSize"),
|
||||||
|
(0x2D, "RequireSignedSMIMEMessages"),
|
||||||
|
(0x2E, "RequireEncryptedSMIMEMessages"),
|
||||||
|
(0x2F, "RequireSignedSMIMEAlgorithm"),
|
||||||
|
(0x30, "RequireEncryptionSMIMEAlgorithm"),
|
||||||
|
(0x31, "AllowSMIMEEncryptionAlgorithmNegotiation"),
|
||||||
|
(0x32, "AllowSMIMESoftCerts"),
|
||||||
|
(0x33, "AllowBrowser"),
|
||||||
|
(0x34, "AllowConsumerEmail"),
|
||||||
|
(0x35, "AllowRemoteDesktop"),
|
||||||
|
(0x36, "AllowInternetSharing"),
|
||||||
|
(0x37, "UnapprovedInROMApplicationList"),
|
||||||
|
(0x38, "ApplicationName"),
|
||||||
|
(0x39, "ApprovedApplicationList"),
|
||||||
|
(0x3A, "Hash"),
|
||||||
|
(0x3B, "AccountOnlyRemoteWipe"),
|
||||||
|
],
|
||||||
|
// 15 Search
|
||||||
|
&[
|
||||||
|
(0x05, "Search"),
|
||||||
|
(0x07, "Store"),
|
||||||
|
(0x08, "Name"),
|
||||||
|
(0x09, "Query"),
|
||||||
|
(0x0A, "Options"),
|
||||||
|
(0x0B, "Range"),
|
||||||
|
(0x0C, "Status"),
|
||||||
|
(0x0D, "Response"),
|
||||||
|
(0x0E, "Result"),
|
||||||
|
(0x0F, "Properties"),
|
||||||
|
(0x10, "Total"),
|
||||||
|
(0x11, "EqualTo"),
|
||||||
|
(0x12, "Value"),
|
||||||
|
(0x13, "And"),
|
||||||
|
(0x14, "Or"),
|
||||||
|
(0x15, "FreeText"),
|
||||||
|
(0x17, "DeepTraversal"),
|
||||||
|
(0x18, "LongId"),
|
||||||
|
(0x19, "RebuildResults"),
|
||||||
|
(0x1A, "LessThan"),
|
||||||
|
(0x1B, "GreaterThan"),
|
||||||
|
(0x1C, "Schema"),
|
||||||
|
(0x1D, "Supported"),
|
||||||
|
(0x1E, "UserName"),
|
||||||
|
(0x1F, "Password"),
|
||||||
|
(0x20, "ConversationId"),
|
||||||
|
(0x21, "Picture"),
|
||||||
|
(0x22, "MaxSize"),
|
||||||
|
(0x23, "MaxPictures"),
|
||||||
|
],
|
||||||
|
// 16 GAL
|
||||||
|
&[
|
||||||
|
(0x05, "DisplayName"),
|
||||||
|
(0x06, "Phone"),
|
||||||
|
(0x07, "Office"),
|
||||||
|
(0x08, "Title"),
|
||||||
|
(0x09, "Company"),
|
||||||
|
(0x0A, "Alias"),
|
||||||
|
(0x0B, "FirstName"),
|
||||||
|
(0x0C, "LastName"),
|
||||||
|
(0x0D, "HomePhone"),
|
||||||
|
(0x0E, "MobilePhone"),
|
||||||
|
(0x0F, "EmailAddress"),
|
||||||
|
(0x10, "Picture"),
|
||||||
|
(0x11, "Status"),
|
||||||
|
(0x12, "Data"),
|
||||||
|
],
|
||||||
|
// 17 AirSyncBase
|
||||||
|
&[
|
||||||
|
(0x05, "BodyPreference"),
|
||||||
|
(0x06, "Type"),
|
||||||
|
(0x07, "TruncationSize"),
|
||||||
|
(0x08, "AllOrNone"),
|
||||||
|
(0x0A, "Body"),
|
||||||
|
(0x0B, "Data"),
|
||||||
|
(0x0C, "EstimatedDataSize"),
|
||||||
|
(0x0D, "Truncated"),
|
||||||
|
(0x0E, "Attachments"),
|
||||||
|
(0x0F, "Attachment"),
|
||||||
|
(0x10, "DisplayName"),
|
||||||
|
(0x11, "FileReference"),
|
||||||
|
(0x12, "Method"),
|
||||||
|
(0x13, "ContentId"),
|
||||||
|
(0x14, "ContentLocation"),
|
||||||
|
(0x15, "IsInline"),
|
||||||
|
(0x16, "NativeBodyType"),
|
||||||
|
(0x17, "ContentType"),
|
||||||
|
(0x18, "Preview"),
|
||||||
|
(0x19, "BodyPartPreference"),
|
||||||
|
(0x1A, "BodyPart"),
|
||||||
|
(0x1B, "Status"),
|
||||||
|
(0x1C, "Add"),
|
||||||
|
(0x1D, "Delete"),
|
||||||
|
(0x1E, "ClientId"),
|
||||||
|
(0x1F, "Content"),
|
||||||
|
(0x20, "Location"),
|
||||||
|
(0x21, "Annotation"),
|
||||||
|
(0x22, "Street"),
|
||||||
|
(0x23, "City"),
|
||||||
|
(0x24, "State"),
|
||||||
|
(0x25, "Country"),
|
||||||
|
(0x26, "PostalCode"),
|
||||||
|
(0x27, "Latitude"),
|
||||||
|
(0x28, "Longitude"),
|
||||||
|
(0x29, "Accuracy"),
|
||||||
|
(0x2A, "Altitude"),
|
||||||
|
(0x2B, "AltitudeAccuracy"),
|
||||||
|
(0x2C, "LocationUri"),
|
||||||
|
(0x2D, "InstanceId"),
|
||||||
|
],
|
||||||
|
// 18 Settings
|
||||||
|
&[
|
||||||
|
(0x05, "Settings"),
|
||||||
|
(0x06, "Status"),
|
||||||
|
(0x07, "Get"),
|
||||||
|
(0x08, "Set"),
|
||||||
|
(0x09, "Oof"),
|
||||||
|
(0x0A, "OofState"),
|
||||||
|
(0x0B, "StartTime"),
|
||||||
|
(0x0C, "EndTime"),
|
||||||
|
(0x0D, "OofMessage"),
|
||||||
|
(0x0E, "AppliesToInternal"),
|
||||||
|
(0x0F, "AppliesToExternalKnown"),
|
||||||
|
(0x10, "AppliesToExternalUnknown"),
|
||||||
|
(0x11, "Enabled"),
|
||||||
|
(0x12, "ReplyMessage"),
|
||||||
|
(0x13, "BodyType"),
|
||||||
|
(0x14, "DevicePassword"),
|
||||||
|
(0x15, "Password"),
|
||||||
|
(0x16, "DeviceInformation"),
|
||||||
|
(0x17, "Model"),
|
||||||
|
(0x18, "IMEI"),
|
||||||
|
(0x19, "FriendlyName"),
|
||||||
|
(0x1A, "OS"),
|
||||||
|
(0x1B, "OSLanguage"),
|
||||||
|
(0x1C, "PhoneNumber"),
|
||||||
|
(0x1D, "UserInformation"),
|
||||||
|
(0x1E, "EmailAddresses"),
|
||||||
|
(0x1F, "SmtpAddress"),
|
||||||
|
(0x20, "UserAgent"),
|
||||||
|
(0x21, "EnableOutboundSMS"),
|
||||||
|
(0x22, "MobileOperator"),
|
||||||
|
(0x23, "PrimarySmtpAddress"),
|
||||||
|
(0x24, "Accounts"),
|
||||||
|
(0x25, "Account"),
|
||||||
|
(0x26, "AccountId"),
|
||||||
|
(0x27, "AccountName"),
|
||||||
|
(0x28, "UserDisplayName"),
|
||||||
|
(0x29, "SendDisabled"),
|
||||||
|
(0x2B, "RightsManagementInformation"),
|
||||||
|
],
|
||||||
|
// 19 DocumentLibrary
|
||||||
|
&[
|
||||||
|
(0x05, "LinkId"),
|
||||||
|
(0x06, "DisplayName"),
|
||||||
|
(0x07, "IsFolder"),
|
||||||
|
(0x08, "CreationDate"),
|
||||||
|
(0x09, "LastModifiedDate"),
|
||||||
|
(0x0A, "IsHidden"),
|
||||||
|
(0x0B, "ContentLength"),
|
||||||
|
(0x0C, "ContentType"),
|
||||||
|
],
|
||||||
|
// 20 ItemOperations
|
||||||
|
&[
|
||||||
|
(0x05, "ItemOperations"),
|
||||||
|
(0x06, "Fetch"),
|
||||||
|
(0x07, "Store"),
|
||||||
|
(0x08, "Options"),
|
||||||
|
(0x09, "Range"),
|
||||||
|
(0x0A, "Total"),
|
||||||
|
(0x0B, "Properties"),
|
||||||
|
(0x0C, "Data"),
|
||||||
|
(0x0D, "Status"),
|
||||||
|
(0x0E, "Response"),
|
||||||
|
(0x0F, "Version"),
|
||||||
|
(0x10, "Schema"),
|
||||||
|
(0x11, "Part"),
|
||||||
|
(0x12, "EmptyFolderContents"),
|
||||||
|
(0x13, "DeleteSubFolders"),
|
||||||
|
(0x14, "UserName"),
|
||||||
|
(0x15, "Password"),
|
||||||
|
(0x16, "Move"),
|
||||||
|
(0x17, "DstFldId"),
|
||||||
|
(0x18, "ConversationId"),
|
||||||
|
(0x19, "MoveAlways"),
|
||||||
|
],
|
||||||
|
// 21 ComposeMail
|
||||||
|
&[
|
||||||
|
(0x05, "SendMail"),
|
||||||
|
(0x06, "SmartForward"),
|
||||||
|
(0x07, "SmartReply"),
|
||||||
|
(0x08, "SaveInSentItems"),
|
||||||
|
(0x09, "ReplaceMime"),
|
||||||
|
(0x0A, "Type"),
|
||||||
|
(0x0B, "Source"),
|
||||||
|
(0x0C, "FolderId"),
|
||||||
|
(0x0D, "ItemId"),
|
||||||
|
(0x0E, "LongId"),
|
||||||
|
(0x0F, "InstanceId"),
|
||||||
|
(0x10, "MIME"),
|
||||||
|
(0x11, "ClientId"),
|
||||||
|
(0x12, "Status"),
|
||||||
|
(0x13, "AccountId"),
|
||||||
|
(0x15, "Forwardees"),
|
||||||
|
(0x16, "Forwardee"),
|
||||||
|
(0x17, "Name"),
|
||||||
|
(0x18, "Email"),
|
||||||
|
],
|
||||||
|
// 22 Email2
|
||||||
|
&[
|
||||||
|
(0x05, "UmCallerId"),
|
||||||
|
(0x06, "UmUserNotes"),
|
||||||
|
(0x07, "UmAttDuration"),
|
||||||
|
(0x08, "UmAttOrder"),
|
||||||
|
(0x09, "ConversationId"),
|
||||||
|
(0x0A, "ConversationIndex"),
|
||||||
|
(0x0B, "LastVerbExecuted"),
|
||||||
|
(0x0C, "LastVerbExecutionTime"),
|
||||||
|
(0x0D, "ReceivedAsBcc"),
|
||||||
|
(0x0E, "Sender"),
|
||||||
|
(0x0F, "CalendarType"),
|
||||||
|
(0x10, "IsLeapMonth"),
|
||||||
|
(0x11, "AccountId"),
|
||||||
|
(0x12, "FirstDayOfWeek"),
|
||||||
|
(0x13, "MeetingMessageType"),
|
||||||
|
(0x15, "IsDraft"),
|
||||||
|
(0x16, "Bcc"),
|
||||||
|
(0x17, "Send"),
|
||||||
|
],
|
||||||
|
// 23 Notes
|
||||||
|
&[
|
||||||
|
(0x05, "Subject"),
|
||||||
|
(0x06, "MessageClass"),
|
||||||
|
(0x07, "LastModifiedDate"),
|
||||||
|
(0x08, "Categories"),
|
||||||
|
(0x09, "Category"),
|
||||||
|
],
|
||||||
|
// 24 RightsManagement
|
||||||
|
&[
|
||||||
|
(0x05, "RightsManagementSupport"),
|
||||||
|
(0x06, "RightsManagementTemplates"),
|
||||||
|
(0x07, "RightsManagementTemplate"),
|
||||||
|
(0x08, "RightsManagementLicense"),
|
||||||
|
(0x09, "EditAllowed"),
|
||||||
|
(0x0A, "ReplyAllowed"),
|
||||||
|
(0x0B, "ReplyAllAllowed"),
|
||||||
|
(0x0C, "ForwardAllowed"),
|
||||||
|
(0x0D, "ModifyRecipientsAllowed"),
|
||||||
|
(0x0E, "ExtractAllowed"),
|
||||||
|
(0x0F, "PrintAllowed"),
|
||||||
|
(0x10, "ExportAllowed"),
|
||||||
|
(0x11, "ProgrammaticAccessAllowed"),
|
||||||
|
(0x12, "Owner"),
|
||||||
|
(0x13, "ContentExpiryDate"),
|
||||||
|
(0x14, "TemplateID"),
|
||||||
|
(0x15, "TemplateName"),
|
||||||
|
(0x16, "TemplateDescription"),
|
||||||
|
(0x17, "ContentOwner"),
|
||||||
|
(0x18, "RemoveRightsManagementProtection"),
|
||||||
|
],
|
||||||
|
// 25 Find
|
||||||
|
&[
|
||||||
|
(0x05, "Find"),
|
||||||
|
(0x06, "SearchId"),
|
||||||
|
(0x07, "ExecuteSearch"),
|
||||||
|
(0x08, "MailBoxSearchCriterion"),
|
||||||
|
(0x09, "Query"),
|
||||||
|
(0x0A, "Status"),
|
||||||
|
(0x0B, "FreeText"),
|
||||||
|
(0x0C, "Options"),
|
||||||
|
(0x0D, "Range"),
|
||||||
|
(0x0E, "DeepTraversal"),
|
||||||
|
(0x11, "Response"),
|
||||||
|
(0x12, "Result"),
|
||||||
|
(0x13, "Properties"),
|
||||||
|
(0x14, "Preview"),
|
||||||
|
(0x15, "HasAttachments"),
|
||||||
|
(0x16, "Total"),
|
||||||
|
(0x17, "DisplayCc"),
|
||||||
|
(0x18, "DisplayBcc"),
|
||||||
|
(0x19, "GalSearchCriterion"),
|
||||||
|
(0x20, "MaxPictures"),
|
||||||
|
(0x21, "MaxSize"),
|
||||||
|
(0x22, "Picture"),
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
//! WBXML (MS-ASWBXML) encoder/decoder with a small element tree.
|
||||||
|
//!
|
||||||
|
//! Elements are addressed by `"Namespace:Tag"` names, e.g. `"AirSync:SyncKey"`.
|
||||||
|
|
||||||
|
mod codepages;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
pub use codepages::NAMESPACES;
|
||||||
|
|
||||||
|
const SWITCH_PAGE: u8 = 0x00;
|
||||||
|
const END: u8 = 0x01;
|
||||||
|
const ENTITY: u8 = 0x02;
|
||||||
|
const STR_I: u8 = 0x03;
|
||||||
|
const LITERAL: u8 = 0x04;
|
||||||
|
const STR_T: u8 = 0x83;
|
||||||
|
const OPAQUE: u8 = 0xC3;
|
||||||
|
const WITH_CONTENT: u8 = 0x40;
|
||||||
|
const WITH_ATTRIBUTES: u8 = 0x80;
|
||||||
|
|
||||||
|
/// Hard limits protecting the decoder against hostile input.
|
||||||
|
const MAX_DEPTH: usize = 64;
|
||||||
|
const MAX_ELEMENTS: usize = 500_000;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum WbxmlError {
|
||||||
|
#[error("unexpected end of WBXML stream")]
|
||||||
|
Eof,
|
||||||
|
#[error("invalid WBXML: {0}")]
|
||||||
|
Invalid(&'static str),
|
||||||
|
#[error("unknown WBXML tag {0}")]
|
||||||
|
UnknownName(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Tables {
|
||||||
|
by_name: HashMap<String, (u8, u8)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tables() -> &'static Tables {
|
||||||
|
static T: OnceLock<Tables> = OnceLock::new();
|
||||||
|
T.get_or_init(|| {
|
||||||
|
let mut by_name = HashMap::new();
|
||||||
|
for (page, tags) in codepages::PAGES.iter().enumerate() {
|
||||||
|
let ns = codepages::NAMESPACES[page];
|
||||||
|
for (code, tag) in tags.iter() {
|
||||||
|
by_name.insert(format!("{ns}:{tag}"), (page as u8, *code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tables { by_name }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a `Namespace:Tag` name into its (page, token) pair.
|
||||||
|
pub fn lookup(name: &str) -> Option<(u8, u8)> {
|
||||||
|
tables().by_name.get(name).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_name(page: u8, code: u8) -> (&'static str, &'static str) {
|
||||||
|
let ns = codepages::NAMESPACES
|
||||||
|
.get(page as usize)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("Unknown");
|
||||||
|
let tag = codepages::PAGES
|
||||||
|
.get(page as usize)
|
||||||
|
.and_then(|t| t.iter().find(|(c, _)| *c == code).map(|(_, n)| *n))
|
||||||
|
.unwrap_or("Unknown");
|
||||||
|
(ns, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Content {
|
||||||
|
None,
|
||||||
|
Text(String),
|
||||||
|
Opaque(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Element {
|
||||||
|
pub page: u8,
|
||||||
|
pub code: u8,
|
||||||
|
pub children: Vec<Element>,
|
||||||
|
pub content: Content,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element {
|
||||||
|
/// Create a new element. Panics on unknown names: tag names are compile-time literals
|
||||||
|
/// in this code base and covered by tests.
|
||||||
|
pub fn new(name: &str) -> Self {
|
||||||
|
let (page, code) = lookup(name).unwrap_or_else(|| panic!("unknown WBXML tag name {name}"));
|
||||||
|
Element {
|
||||||
|
page,
|
||||||
|
code,
|
||||||
|
children: Vec::new(),
|
||||||
|
content: Content::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ns(&self) -> &'static str {
|
||||||
|
tag_name(self.page, self.code).0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tag(&self) -> &'static str {
|
||||||
|
tag_name(self.page, self.code).1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> String {
|
||||||
|
let (ns, tag) = tag_name(self.page, self.code);
|
||||||
|
format!("{ns}:{tag}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is(&self, name: &str) -> bool {
|
||||||
|
lookup(name) == Some((self.page, self.code))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- builder helpers -----
|
||||||
|
|
||||||
|
pub fn text(name: &str, value: impl Into<String>) -> Self {
|
||||||
|
let mut e = Element::new(name);
|
||||||
|
e.content = Content::Text(value.into());
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn opaque(name: &str, value: Vec<u8>) -> Self {
|
||||||
|
let mut e = Element::new(name);
|
||||||
|
e.content = Content::Opaque(value);
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with(mut self, child: Element) -> Self {
|
||||||
|
self.children.push(child);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_text(mut self, name: &str, value: impl Into<String>) -> Self {
|
||||||
|
self.children.push(Element::text(name, value));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_opt(mut self, name: &str, value: Option<impl Into<String>>) -> Self {
|
||||||
|
if let Some(v) = value {
|
||||||
|
self.children.push(Element::text(name, v));
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_empty(mut self, name: &str) -> Self {
|
||||||
|
self.children.push(Element::new(name));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, child: Element) {
|
||||||
|
self.children.push(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_text(&mut self, name: &str, value: impl Into<String>) {
|
||||||
|
self.children.push(Element::text(name, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_opt(&mut self, name: &str, value: Option<impl Into<String>>) {
|
||||||
|
if let Some(v) = value {
|
||||||
|
self.children.push(Element::text(name, v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- query helpers -----
|
||||||
|
|
||||||
|
pub fn child(&self, name: &str) -> Option<&Element> {
|
||||||
|
let key = lookup(name)?;
|
||||||
|
self.children.iter().find(|c| (c.page, c.code) == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn children_named<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a Element> + 'a {
|
||||||
|
let key = lookup(name);
|
||||||
|
self.children
|
||||||
|
.iter()
|
||||||
|
.filter(move |c| Some((c.page, c.code)) == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text of a direct child element.
|
||||||
|
pub fn child_text(&self, name: &str) -> Option<String> {
|
||||||
|
self.child(name).map(|c| c.text_value())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn child_u32(&self, name: &str) -> Option<u32> {
|
||||||
|
self.child_text(name).and_then(|t| t.trim().parse().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has(&self, name: &str) -> bool {
|
||||||
|
self.child(name).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow a path of direct children.
|
||||||
|
pub fn path(&self, names: &[&str]) -> Option<&Element> {
|
||||||
|
let mut cur = self;
|
||||||
|
for n in names {
|
||||||
|
cur = cur.child(n)?;
|
||||||
|
}
|
||||||
|
Some(cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text_value(&self) -> String {
|
||||||
|
match &self.content {
|
||||||
|
Content::Text(s) => s.clone(),
|
||||||
|
Content::Opaque(b) => String::from_utf8_lossy(b).into_owned(),
|
||||||
|
Content::None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bytes_value(&self) -> Vec<u8> {
|
||||||
|
match &self.content {
|
||||||
|
Content::Text(s) => s.as_bytes().to_vec(),
|
||||||
|
Content::Opaque(b) => b.clone(),
|
||||||
|
Content::None => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human readable XML rendering for debug logs (data truncated).
|
||||||
|
pub fn to_xml(&self) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
self.write_xml(&mut out, 0);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_xml(&self, out: &mut String, depth: usize) {
|
||||||
|
let pad = " ".repeat(depth);
|
||||||
|
let (ns, tag) = tag_name(self.page, self.code);
|
||||||
|
let name = format!("{ns}:{tag}");
|
||||||
|
match (&self.content, self.children.is_empty()) {
|
||||||
|
(Content::None, true) => {
|
||||||
|
let _ = writeln!(out, "{pad}<{name}/>");
|
||||||
|
}
|
||||||
|
(Content::Text(t), _) => {
|
||||||
|
let shown = if tag == "Password" || tag == "Data" && t.len() > 256 {
|
||||||
|
format!("[{} chars]", t.len())
|
||||||
|
} else if t.len() > 256 {
|
||||||
|
format!("{}…", &t[..t.floor_char_boundary_compat(256)])
|
||||||
|
} else {
|
||||||
|
t.clone()
|
||||||
|
};
|
||||||
|
let _ = writeln!(out, "{pad}<{name}>{}</{name}>", xml_escape(&shown));
|
||||||
|
}
|
||||||
|
(Content::Opaque(b), _) => {
|
||||||
|
let _ = writeln!(out, "{pad}<{name}>[opaque {} bytes]</{name}>", b.len());
|
||||||
|
}
|
||||||
|
(Content::None, false) => {
|
||||||
|
let _ = writeln!(out, "{pad}<{name}>");
|
||||||
|
for c in &self.children {
|
||||||
|
c.write_xml(out, depth + 1);
|
||||||
|
}
|
||||||
|
let _ = writeln!(out, "{pad}</{name}>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait FloorBoundary {
|
||||||
|
fn floor_char_boundary_compat(&self, idx: usize) -> usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FloorBoundary for String {
|
||||||
|
fn floor_char_boundary_compat(&self, idx: usize) -> usize {
|
||||||
|
let mut i = idx.min(self.len());
|
||||||
|
while i > 0 && !self.is_char_boundary(i) {
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xml_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- encoder
|
||||||
|
|
||||||
|
pub fn encode(root: &Element) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(512);
|
||||||
|
out.extend_from_slice(&[0x03, 0x01, 0x6A, 0x00]);
|
||||||
|
let mut page = 0u8;
|
||||||
|
encode_element(root, &mut out, &mut page);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_element(e: &Element, out: &mut Vec<u8>, page: &mut u8) {
|
||||||
|
if e.page != *page {
|
||||||
|
out.push(SWITCH_PAGE);
|
||||||
|
out.push(e.page);
|
||||||
|
*page = e.page;
|
||||||
|
}
|
||||||
|
let has_content = !e.children.is_empty() || !matches!(e.content, Content::None);
|
||||||
|
out.push(if has_content {
|
||||||
|
e.code | WITH_CONTENT
|
||||||
|
} else {
|
||||||
|
e.code
|
||||||
|
});
|
||||||
|
if !has_content {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match &e.content {
|
||||||
|
Content::Text(t) => {
|
||||||
|
out.push(STR_I);
|
||||||
|
// NUL bytes would terminate the inline string; strip them.
|
||||||
|
out.extend(t.bytes().filter(|b| *b != 0));
|
||||||
|
out.push(0);
|
||||||
|
}
|
||||||
|
Content::Opaque(b) => {
|
||||||
|
out.push(OPAQUE);
|
||||||
|
write_mb_u32(out, b.len() as u32);
|
||||||
|
out.extend_from_slice(b);
|
||||||
|
}
|
||||||
|
Content::None => {}
|
||||||
|
}
|
||||||
|
for c in &e.children {
|
||||||
|
encode_element(c, out, page);
|
||||||
|
}
|
||||||
|
out.push(END);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_mb_u32(out: &mut Vec<u8>, mut v: u32) {
|
||||||
|
let mut buf = [0u8; 5];
|
||||||
|
let mut i = 4;
|
||||||
|
buf[i] = (v & 0x7f) as u8;
|
||||||
|
v >>= 7;
|
||||||
|
while v > 0 {
|
||||||
|
i -= 1;
|
||||||
|
buf[i] = 0x80 | (v & 0x7f) as u8;
|
||||||
|
v >>= 7;
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&buf[i..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- decoder
|
||||||
|
|
||||||
|
struct Reader<'a> {
|
||||||
|
data: &'a [u8],
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Reader<'a> {
|
||||||
|
fn byte(&mut self) -> Result<u8, WbxmlError> {
|
||||||
|
let b = *self.data.get(self.pos).ok_or(WbxmlError::Eof)?;
|
||||||
|
self.pos += 1;
|
||||||
|
Ok(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek(&self) -> Option<u8> {
|
||||||
|
self.data.get(self.pos).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mb_u32(&mut self) -> Result<u32, WbxmlError> {
|
||||||
|
let mut v: u32 = 0;
|
||||||
|
for _ in 0..5 {
|
||||||
|
let b = self.byte()?;
|
||||||
|
v = v
|
||||||
|
.checked_shl(7)
|
||||||
|
.ok_or(WbxmlError::Invalid("mb_u_int32 overflow"))?
|
||||||
|
| (b & 0x7f) as u32;
|
||||||
|
if b & 0x80 == 0 {
|
||||||
|
return Ok(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(WbxmlError::Invalid("mb_u_int32 too long"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cstr(&mut self) -> Result<Vec<u8>, WbxmlError> {
|
||||||
|
let start = self.pos;
|
||||||
|
let rel = self.data[start..]
|
||||||
|
.iter()
|
||||||
|
.position(|b| *b == 0)
|
||||||
|
.ok_or(WbxmlError::Eof)?;
|
||||||
|
self.pos = start + rel + 1;
|
||||||
|
Ok(self.data[start..start + rel].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(&mut self, n: usize) -> Result<&'a [u8], WbxmlError> {
|
||||||
|
if self.pos + n > self.data.len() {
|
||||||
|
return Err(WbxmlError::Eof);
|
||||||
|
}
|
||||||
|
let s = &self.data[self.pos..self.pos + n];
|
||||||
|
self.pos += n;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(data: &[u8]) -> Result<Element, WbxmlError> {
|
||||||
|
let mut r = Reader { data, pos: 0 };
|
||||||
|
let _version = r.byte()?;
|
||||||
|
let _public_id = r.mb_u32()?;
|
||||||
|
let _charset = r.mb_u32()?;
|
||||||
|
let strtbl_len = r.mb_u32()? as usize;
|
||||||
|
let strtbl = r.take(strtbl_len)?.to_vec();
|
||||||
|
let mut page = 0u8;
|
||||||
|
let mut count = 0usize;
|
||||||
|
// skip leading switch pages
|
||||||
|
loop {
|
||||||
|
match r.peek() {
|
||||||
|
Some(SWITCH_PAGE) => {
|
||||||
|
r.byte()?;
|
||||||
|
page = r.byte()?;
|
||||||
|
}
|
||||||
|
Some(_) => break,
|
||||||
|
None => return Err(WbxmlError::Eof),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decode_element(&mut r, &mut page, &strtbl, 0, &mut count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_element(
|
||||||
|
r: &mut Reader,
|
||||||
|
page: &mut u8,
|
||||||
|
strtbl: &[u8],
|
||||||
|
depth: usize,
|
||||||
|
count: &mut usize,
|
||||||
|
) -> Result<Element, WbxmlError> {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return Err(WbxmlError::Invalid("nesting too deep"));
|
||||||
|
}
|
||||||
|
*count += 1;
|
||||||
|
if *count > MAX_ELEMENTS {
|
||||||
|
return Err(WbxmlError::Invalid("too many elements"));
|
||||||
|
}
|
||||||
|
let tok = r.byte()?;
|
||||||
|
if tok == LITERAL {
|
||||||
|
return Err(WbxmlError::Invalid("literal tags not supported"));
|
||||||
|
}
|
||||||
|
if tok & WITH_ATTRIBUTES != 0 {
|
||||||
|
return Err(WbxmlError::Invalid("attributes not supported"));
|
||||||
|
}
|
||||||
|
let mut el = Element {
|
||||||
|
page: *page,
|
||||||
|
code: tok & 0x3f,
|
||||||
|
children: Vec::new(),
|
||||||
|
content: Content::None,
|
||||||
|
};
|
||||||
|
if tok & WITH_CONTENT == 0 {
|
||||||
|
return Ok(el);
|
||||||
|
}
|
||||||
|
let mut text: Option<Vec<u8>> = None;
|
||||||
|
let mut opaque: Option<Vec<u8>> = None;
|
||||||
|
loop {
|
||||||
|
let next = r.peek().ok_or(WbxmlError::Eof)?;
|
||||||
|
match next {
|
||||||
|
END => {
|
||||||
|
r.byte()?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
SWITCH_PAGE => {
|
||||||
|
r.byte()?;
|
||||||
|
*page = r.byte()?;
|
||||||
|
}
|
||||||
|
STR_I => {
|
||||||
|
r.byte()?;
|
||||||
|
let s = r.cstr()?;
|
||||||
|
text.get_or_insert_with(Vec::new).extend_from_slice(&s);
|
||||||
|
}
|
||||||
|
STR_T => {
|
||||||
|
r.byte()?;
|
||||||
|
let off = r.mb_u32()? as usize;
|
||||||
|
let rest = strtbl
|
||||||
|
.get(off..)
|
||||||
|
.ok_or(WbxmlError::Invalid("bad string table offset"))?;
|
||||||
|
let end = rest.iter().position(|b| *b == 0).unwrap_or(rest.len());
|
||||||
|
text.get_or_insert_with(Vec::new)
|
||||||
|
.extend_from_slice(&rest[..end]);
|
||||||
|
}
|
||||||
|
ENTITY => {
|
||||||
|
r.byte()?;
|
||||||
|
let cp = r.mb_u32()?;
|
||||||
|
if let Some(c) = char::from_u32(cp) {
|
||||||
|
let mut b = [0u8; 4];
|
||||||
|
text.get_or_insert_with(Vec::new)
|
||||||
|
.extend_from_slice(c.encode_utf8(&mut b).as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OPAQUE => {
|
||||||
|
r.byte()?;
|
||||||
|
let len = r.mb_u32()? as usize;
|
||||||
|
let b = r.take(len)?;
|
||||||
|
opaque.get_or_insert_with(Vec::new).extend_from_slice(b);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let child = decode_element(r, page, strtbl, depth + 1, count)?;
|
||||||
|
el.children.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(o) = opaque {
|
||||||
|
el.content = Content::Opaque(o);
|
||||||
|
} else if let Some(t) = text {
|
||||||
|
el.content = Content::Text(String::from_utf8_lossy(&t).into_owned());
|
||||||
|
}
|
||||||
|
Ok(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip() {
|
||||||
|
let root = Element::new("AirSync:Sync").with(
|
||||||
|
Element::new("AirSync:Collections").with(
|
||||||
|
Element::new("AirSync:Collection")
|
||||||
|
.with_text("AirSync:SyncKey", "{abc}1")
|
||||||
|
.with_text("AirSync:CollectionId", "5")
|
||||||
|
.with_empty("AirSync:GetChanges")
|
||||||
|
.with(
|
||||||
|
Element::new("AirSync:Options")
|
||||||
|
.with(
|
||||||
|
Element::new("AirSyncBase:BodyPreference")
|
||||||
|
.with_text("AirSyncBase:Type", "2"),
|
||||||
|
)
|
||||||
|
.with(Element::opaque("ComposeMail:MIME", vec![0, 1, 2, 255])),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let bytes = encode(&root);
|
||||||
|
let back = decode(&bytes).unwrap();
|
||||||
|
assert_eq!(root, back);
|
||||||
|
let col = back
|
||||||
|
.path(&["AirSync:Collections", "AirSync:Collection"])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(col.child_text("AirSync:SyncKey").unwrap(), "{abc}1");
|
||||||
|
assert!(col.has("AirSync:GetChanges"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mb_u32() {
|
||||||
|
for v in [0u32, 1, 127, 128, 300, 16383, 16384, 1 << 28, u32::MAX] {
|
||||||
|
let mut out = vec![];
|
||||||
|
write_mb_u32(&mut out, v);
|
||||||
|
let mut r = Reader { data: &out, pos: 0 };
|
||||||
|
assert_eq!(r.mb_u32().unwrap(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_names_unique_per_page() {
|
||||||
|
for (p, tags) in codepages::PAGES.iter().enumerate() {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for (c, _) in tags.iter() {
|
||||||
|
assert!(seen.insert(*c), "duplicate token {c:#x} on page {p}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_garbage() {
|
||||||
|
assert!(decode(&[0x03, 0x01, 0x6a, 0x00, 0x45]).is_err());
|
||||||
|
assert!(decode(&[]).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
//! Tiny namespace-aware XML DOM built on quick-xml (no DTD / entity expansion).
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use quick_xml::events::Event;
|
||||||
|
use quick_xml::name::ResolveResult;
|
||||||
|
use quick_xml::NsReader;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct XNode {
|
||||||
|
pub ns: String,
|
||||||
|
pub name: String,
|
||||||
|
pub text: String,
|
||||||
|
pub attrs: Vec<(String, String)>,
|
||||||
|
pub children: Vec<XNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XNode {
|
||||||
|
pub fn attr(&self, name: &str) -> Option<&str> {
|
||||||
|
self.attrs
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||||
|
.map(|(_, v)| v.as_str())
|
||||||
|
}
|
||||||
|
pub fn child(&self, name: &str) -> Option<&XNode> {
|
||||||
|
self.children
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.name.eq_ignore_ascii_case(name))
|
||||||
|
}
|
||||||
|
pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a XNode> + 'a {
|
||||||
|
self.children
|
||||||
|
.iter()
|
||||||
|
.filter(move |c| c.name.eq_ignore_ascii_case(name))
|
||||||
|
}
|
||||||
|
/// Depth-first search for the first descendant with this local name.
|
||||||
|
pub fn find(&self, name: &str) -> Option<&XNode> {
|
||||||
|
for c in &self.children {
|
||||||
|
if c.name.eq_ignore_ascii_case(name) {
|
||||||
|
return Some(c);
|
||||||
|
}
|
||||||
|
if let Some(f) = c.find(name) {
|
||||||
|
return Some(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
pub fn find_all<'a>(&'a self, name: &str, out: &mut Vec<&'a XNode>) {
|
||||||
|
for c in &self.children {
|
||||||
|
if c.name.eq_ignore_ascii_case(name) {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
c.find_all(name, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn text_of(&self, name: &str) -> Option<String> {
|
||||||
|
self.find(name).map(|n| n.text.trim().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attrs_of(e: &quick_xml::events::BytesStart) -> Vec<(String, String)> {
|
||||||
|
e.attributes()
|
||||||
|
.flatten()
|
||||||
|
.take(64)
|
||||||
|
.map(|a| {
|
||||||
|
(
|
||||||
|
String::from_utf8_lossy(a.key.local_name().as_ref()).into_owned(),
|
||||||
|
a.unescape_value()
|
||||||
|
.map(|v| v.into_owned())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(xml: &[u8]) -> Result<XNode> {
|
||||||
|
if xml.len() > 64 * 1024 * 1024 {
|
||||||
|
bail!("XML document too large");
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(xml);
|
||||||
|
// Refuse DTDs outright (XXE / billion laughs protection).
|
||||||
|
if text.contains("<!DOCTYPE") || text.contains("<!ENTITY") {
|
||||||
|
bail!("XML DTD not allowed");
|
||||||
|
}
|
||||||
|
let mut reader = NsReader::from_str(&text);
|
||||||
|
reader.config_mut().trim_text(false);
|
||||||
|
let mut stack: Vec<XNode> = vec![XNode::default()];
|
||||||
|
let mut depth = 0usize;
|
||||||
|
loop {
|
||||||
|
match reader.read_resolved_event()? {
|
||||||
|
(ns, Event::Start(e)) => {
|
||||||
|
depth += 1;
|
||||||
|
if depth > 128 {
|
||||||
|
bail!("XML nesting too deep");
|
||||||
|
}
|
||||||
|
let ns = match ns {
|
||||||
|
ResolveResult::Bound(n) => String::from_utf8_lossy(n.as_ref()).into_owned(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
stack.push(XNode {
|
||||||
|
ns,
|
||||||
|
name: String::from_utf8_lossy(e.local_name().as_ref()).into_owned(),
|
||||||
|
attrs: attrs_of(&e),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(ns, Event::Empty(e)) => {
|
||||||
|
let ns = match ns {
|
||||||
|
ResolveResult::Bound(n) => String::from_utf8_lossy(n.as_ref()).into_owned(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let node = XNode {
|
||||||
|
ns,
|
||||||
|
name: String::from_utf8_lossy(e.local_name().as_ref()).into_owned(),
|
||||||
|
attrs: attrs_of(&e),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if let Some(top) = stack.last_mut() {
|
||||||
|
top.children.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(_, Event::End(_)) => {
|
||||||
|
depth = depth.saturating_sub(1);
|
||||||
|
let node = stack.pop().unwrap_or_default();
|
||||||
|
match stack.last_mut() {
|
||||||
|
Some(top) => top.children.push(node),
|
||||||
|
None => bail!("unbalanced XML"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(_, Event::Text(t)) => {
|
||||||
|
if let Some(top) = stack.last_mut() {
|
||||||
|
let s = t.unescape().map(|c| c.into_owned()).unwrap_or_default();
|
||||||
|
top.text.push_str(&s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(_, Event::CData(t)) => {
|
||||||
|
if let Some(top) = stack.last_mut() {
|
||||||
|
top.text.push_str(&String::from_utf8_lossy(&t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(_, Event::Eof) => break,
|
||||||
|
(_, Event::DocType(_)) => bail!("XML DTD not allowed"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let root = stack.pop().unwrap_or_default();
|
||||||
|
root.children
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("empty XML document"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn parse_ns() {
|
||||||
|
let x = br#"<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/a/b.ics</d:href><d:propstat><d:prop><d:getetag>"x"</d:getetag></d:prop></d:propstat></d:response></d:multistatus>"#;
|
||||||
|
let n = super::parse(x).unwrap();
|
||||||
|
assert_eq!(n.name, "multistatus");
|
||||||
|
assert_eq!(n.ns, "DAV:");
|
||||||
|
assert_eq!(n.text_of("getetag").unwrap(), "\"x\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_dtd() {
|
||||||
|
assert!(super::parse(b"<!DOCTYPE x [<!ENTITY a \"b\">]><x>&a;</x>").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,634 @@
|
|||||||
|
//! End-to-end test against a running ActivePush with the dev backends
|
||||||
|
//! (`tools/dev/test-backends.sh` + `tools/dev/test-settings.json`).
|
||||||
|
//!
|
||||||
|
//! AP_E2E_URL=http://127.0.0.1:18081 cargo test --test e2e -- --nocapture --test-threads=1
|
||||||
|
//!
|
||||||
|
//! Skipped when AP_E2E_URL is not set.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use active_push::backend::imap::ImapConn;
|
||||||
|
use active_push::config::{ImapSettings, TlsMode};
|
||||||
|
use active_push::probe::Probe;
|
||||||
|
use active_push::wbxml::Element;
|
||||||
|
|
||||||
|
const USER: &str = "jan@test.local";
|
||||||
|
const PASS: &str = "Test-Password-123";
|
||||||
|
|
||||||
|
fn url() -> Option<String> {
|
||||||
|
std::env::var("AP_E2E_URL")
|
||||||
|
.ok()
|
||||||
|
.map(|u| format!("{}/Microsoft-Server-ActiveSync", u.trim_end_matches('/')))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn imap() -> ImapConn {
|
||||||
|
let cfg = ImapSettings {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: 11143,
|
||||||
|
security: TlsMode::None,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut c = ImapConn::connect(&cfg).await.unwrap();
|
||||||
|
assert!(c.login(USER, PASS).await.unwrap());
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Folders {
|
||||||
|
list: Vec<(String, String, String, String)>, // id, parent, name, type
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Folders {
|
||||||
|
fn by_type(&self, t: &str) -> String {
|
||||||
|
self.list
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.3 == t)
|
||||||
|
.map(|f| f.0.clone())
|
||||||
|
.unwrap_or_else(|| panic!("no folder of type {t}"))
|
||||||
|
}
|
||||||
|
fn by_name(&self, n: &str) -> (String, String) {
|
||||||
|
self.list
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.2 == n)
|
||||||
|
.map(|f| (f.0.clone(), f.1.clone()))
|
||||||
|
.unwrap_or_else(|| panic!("no folder {n}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn col(r: &Element) -> &Element {
|
||||||
|
r.path(&["AirSync:Collections", "AirSync:Collection"])
|
||||||
|
.expect("collection in sync response")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn full_sync(p: &Probe, id: &str) -> (String, Vec<Element>) {
|
||||||
|
let r = p.sync(id, "0", 0, None).await.unwrap();
|
||||||
|
let mut key = col(&r).child_text("AirSync:SyncKey").unwrap();
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for _ in 0..50 {
|
||||||
|
let r = p.sync(id, &key, 100, None).await.unwrap();
|
||||||
|
let c = col(&r);
|
||||||
|
assert_eq!(
|
||||||
|
c.child_text("AirSync:Status").as_deref(),
|
||||||
|
Some("1"),
|
||||||
|
"sync status: {}",
|
||||||
|
r.to_xml()
|
||||||
|
);
|
||||||
|
key = c.child_text("AirSync:SyncKey").unwrap();
|
||||||
|
if let Some(cmds) = c.child("AirSync:Commands") {
|
||||||
|
items.extend(cmds.children.iter().cloned());
|
||||||
|
}
|
||||||
|
if !c.has("AirSync:MoreAvailable") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(key, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn changes(p: &Probe, id: &str, key: &str, cmds: Option<Element>) -> (String, Element) {
|
||||||
|
let r = p.sync(id, key, 100, cmds).await.unwrap();
|
||||||
|
let c = col(&r).clone();
|
||||||
|
(c.child_text("AirSync:SyncKey").unwrap(), c)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn e2e_full_flow() {
|
||||||
|
let Some(url) = url() else {
|
||||||
|
eprintln!("AP_E2E_URL not set - skipping end-to-end test");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let dev = format!("e2e{}", std::process::id());
|
||||||
|
// seed fresh messages with an attachment
|
||||||
|
{
|
||||||
|
let mut im = imap().await;
|
||||||
|
for f in ["Zmieniony", "Nowy folder &AXw-"] {
|
||||||
|
let _ = im.delete(f).await;
|
||||||
|
}
|
||||||
|
for i in 1..=3 {
|
||||||
|
let m = format!("From: Anna Nowak <anna@test.local>\r\nTo: Jan <jan@test.local>\r\nSubject: Testowa wiadomość e2e {i}\r\nMessage-ID: <e2e{i}.{dev}@test.local>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=b1\r\n\r\n--b1\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nTreść wiadomości numer {i}.\r\n--b1\r\nContent-Type: application/pdf; name=\"raport.pdf\"\r\nContent-Disposition: attachment; filename=\"raport.pdf\"\r\nContent-Transfer-Encoding: base64\r\n\r\nJVBERi0xLjQKJcOkw7zDtsOfCg==\r\n--b1--\r\n");
|
||||||
|
im.append("INBOX", "", m.as_bytes()).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut p = Probe::new(&url, USER, PASS, &dev, true).unwrap();
|
||||||
|
|
||||||
|
// ---- auth failure gives 401
|
||||||
|
let bad = Probe::new(&url, USER, "wrong-password", &dev, true).unwrap();
|
||||||
|
assert_eq!(bad.options().await.unwrap().0, 401);
|
||||||
|
|
||||||
|
// ---- provisioning is enforced
|
||||||
|
let (st, _) = p
|
||||||
|
.cmd(
|
||||||
|
"FolderSync",
|
||||||
|
Some(
|
||||||
|
Element::new("FolderHierarchy:FolderSync")
|
||||||
|
.with_text("FolderHierarchy:SyncKey", "0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
st == 449 || st == 200,
|
||||||
|
"unprovisioned FolderSync answered {st}"
|
||||||
|
);
|
||||||
|
p.provision().await.unwrap();
|
||||||
|
|
||||||
|
// ---- hierarchy
|
||||||
|
let fs = p.folder_sync("0").await.unwrap();
|
||||||
|
let fkey = fs.child_text("FolderHierarchy:SyncKey").unwrap();
|
||||||
|
let mut list = Vec::new();
|
||||||
|
for a in fs
|
||||||
|
.child("FolderHierarchy:Changes")
|
||||||
|
.unwrap()
|
||||||
|
.children_named("FolderHierarchy:Add")
|
||||||
|
{
|
||||||
|
list.push((
|
||||||
|
a.child_text("FolderHierarchy:ServerId").unwrap(),
|
||||||
|
a.child_text("FolderHierarchy:ParentId").unwrap(),
|
||||||
|
a.child_text("FolderHierarchy:DisplayName").unwrap(),
|
||||||
|
a.child_text("FolderHierarchy:Type").unwrap(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let f = Folders { list };
|
||||||
|
let inbox = f.by_type("2");
|
||||||
|
let trash = f.by_type("4");
|
||||||
|
let sent = f.by_type("5");
|
||||||
|
let cal = f.by_type("8");
|
||||||
|
let tasks = f.by_type("7");
|
||||||
|
let contacts = f.by_type("9");
|
||||||
|
let (projekty, _) = f.by_name("Projekty");
|
||||||
|
let (_, alfa_parent) = f.by_name("Alfa");
|
||||||
|
assert_eq!(alfa_parent, projekty, "subfolder parent");
|
||||||
|
// unchanged hierarchy keeps key
|
||||||
|
let fs2 = p.folder_sync(&fkey).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs2.path(&["FolderHierarchy:Changes", "FolderHierarchy:Count"])
|
||||||
|
.unwrap()
|
||||||
|
.text_value(),
|
||||||
|
"0"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Z-Push leftovers: a collection id unknown here must be deleted on the device
|
||||||
|
let zr = p
|
||||||
|
.sync(
|
||||||
|
"a6271970",
|
||||||
|
"{11111111-2222-3333-4444-555555555555}7",
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(col(&zr).child_text("AirSync:Status").as_deref(), Some("12"));
|
||||||
|
let fs3 = p.folder_sync(&fkey).await.unwrap();
|
||||||
|
let ch3 = fs3.child("FolderHierarchy:Changes").expect("changes");
|
||||||
|
assert!(
|
||||||
|
ch3.children_named("FolderHierarchy:Delete")
|
||||||
|
.any(|d| d.child_text("FolderHierarchy:ServerId").as_deref() == Some("a6271970")),
|
||||||
|
"stale collection deleted: {}",
|
||||||
|
fs3.to_xml()
|
||||||
|
);
|
||||||
|
let fkey = fs3.child_text("FolderHierarchy:SyncKey").unwrap();
|
||||||
|
|
||||||
|
// ---- mail
|
||||||
|
let (mut ikey, mails) = full_sync(&p, &inbox).await;
|
||||||
|
assert!(mails.len() >= 3, "expected mails, got {}", mails.len());
|
||||||
|
let mails: Vec<Element> = mails
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| {
|
||||||
|
m.child("AirSync:ApplicationData")
|
||||||
|
.and_then(|a| a.child_text("Email:Subject"))
|
||||||
|
.map(|s| s.starts_with("Testowa wiadomo"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(mails.len() >= 3, "seeded test mails present");
|
||||||
|
let first = &mails[0];
|
||||||
|
let app = first.child("AirSync:ApplicationData").unwrap();
|
||||||
|
assert!(app
|
||||||
|
.child_text("Email:Subject")
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("Testowa wiadomo"));
|
||||||
|
let fref = app
|
||||||
|
.path(&["AirSyncBase:Attachments", "AirSyncBase:Attachment"])
|
||||||
|
.and_then(|a| a.child_text("AirSyncBase:FileReference"))
|
||||||
|
.expect("attachment");
|
||||||
|
// attachment via ItemOperations
|
||||||
|
let io = Element::new("ItemOperations:ItemOperations").with(
|
||||||
|
Element::new("ItemOperations:Fetch")
|
||||||
|
.with_text("ItemOperations:Store", "Mailbox")
|
||||||
|
.with_text("AirSyncBase:FileReference", fref.clone()),
|
||||||
|
);
|
||||||
|
let (_, r) = p.cmd("ItemOperations", Some(io)).await.unwrap();
|
||||||
|
let r = r.unwrap();
|
||||||
|
let data = r
|
||||||
|
.path(&[
|
||||||
|
"ItemOperations:Response",
|
||||||
|
"ItemOperations:Fetch",
|
||||||
|
"ItemOperations:Properties",
|
||||||
|
])
|
||||||
|
.and_then(|x| x.child_text("ItemOperations:Data"))
|
||||||
|
.expect("attachment data");
|
||||||
|
assert_eq!(data, "JVBERi0xLjQKJcOkw7zDtsOfCg==");
|
||||||
|
|
||||||
|
// client marks read + flag
|
||||||
|
let sid = first.child_text("AirSync:ServerId").unwrap();
|
||||||
|
let ch = Element::new("AirSync:Commands").with(
|
||||||
|
Element::new("AirSync:Change")
|
||||||
|
.with_text("AirSync:ServerId", sid.clone())
|
||||||
|
.with(
|
||||||
|
Element::new("AirSync:ApplicationData")
|
||||||
|
.with_text("Email:Read", "1")
|
||||||
|
.with(Element::new("Email:Flag").with_text("Email:FlagStatus", "2")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let (k, c) = changes(&p, &inbox, &ikey, Some(ch)).await;
|
||||||
|
ikey = k;
|
||||||
|
assert!(
|
||||||
|
c.child("AirSync:Commands").is_none(),
|
||||||
|
"own change must not be echoed: {}",
|
||||||
|
c.to_xml()
|
||||||
|
);
|
||||||
|
let uid: u32 = sid.split(':').nth(1).unwrap().parse().unwrap();
|
||||||
|
let mut im = imap().await;
|
||||||
|
im.select("INBOX").await.unwrap();
|
||||||
|
let fl = im.uid_fetch_flags(&uid.to_string()).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
fl[0].flags.iter().any(|x| x == "\\Seen") && fl[0].flags.iter().any(|x| x == "\\Flagged")
|
||||||
|
);
|
||||||
|
|
||||||
|
// server side change -> device gets Change
|
||||||
|
im.uid_store(uid, "-", "\\Flagged").await.unwrap();
|
||||||
|
let (k, c) = changes(&p, &inbox, &ikey, None).await;
|
||||||
|
ikey = k;
|
||||||
|
let cmds = c.child("AirSync:Commands").expect("server change");
|
||||||
|
assert!(cmds.children.iter().any(|x| x.is("AirSync:Change")));
|
||||||
|
|
||||||
|
// new mail arrives -> Add
|
||||||
|
im.append(
|
||||||
|
"INBOX",
|
||||||
|
"",
|
||||||
|
b"From: x@y.pl\r\nTo: jan@test.local\r\nSubject: Nowa\r\n\r\nhej\r\n",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (k, c) = changes(&p, &inbox, &ikey, None).await;
|
||||||
|
ikey = k;
|
||||||
|
assert!(c
|
||||||
|
.child("AirSync:Commands")
|
||||||
|
.unwrap()
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.any(|x| x.is("AirSync:Add")));
|
||||||
|
|
||||||
|
// delete from device -> Trash
|
||||||
|
let ch = Element::new("AirSync:Commands")
|
||||||
|
.with(Element::new("AirSync:Delete").with_text("AirSync:ServerId", sid.clone()));
|
||||||
|
let (k, _) = changes(&p, &inbox, &ikey, Some(ch)).await;
|
||||||
|
ikey = k;
|
||||||
|
im.select("Trash").await.unwrap();
|
||||||
|
assert!(
|
||||||
|
!im.uid_search("ALL").await.unwrap().is_empty(),
|
||||||
|
"message moved to Trash"
|
||||||
|
);
|
||||||
|
|
||||||
|
// GetItemEstimate (nothing pending)
|
||||||
|
let gie = Element::new("GetItemEstimate:GetItemEstimate").with(
|
||||||
|
Element::new("GetItemEstimate:Collections").with(
|
||||||
|
Element::new("GetItemEstimate:Collection")
|
||||||
|
.with_text("AirSync:SyncKey", ikey.clone())
|
||||||
|
.with_text("GetItemEstimate:CollectionId", inbox.clone()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let (_, r) = p.cmd("GetItemEstimate", Some(gie)).await.unwrap();
|
||||||
|
let r = r.unwrap();
|
||||||
|
let est_dbg = r.to_xml();
|
||||||
|
let est = r
|
||||||
|
.path(&["GetItemEstimate:Response", "GetItemEstimate:Collection"])
|
||||||
|
.unwrap()
|
||||||
|
.child_text("GetItemEstimate:Estimate")
|
||||||
|
.unwrap();
|
||||||
|
if est != "0" {
|
||||||
|
let (_, c) = changes(&p, &inbox, &ikey, None).await;
|
||||||
|
panic!("estimate {est}: {est_dbg}\npending: {}", c.to_xml());
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveItems inbox -> Projekty
|
||||||
|
let (_, c) = changes(&p, &inbox, &ikey, None).await;
|
||||||
|
let _ = c;
|
||||||
|
let mv_sid = mails[1].child_text("AirSync:ServerId").unwrap();
|
||||||
|
let mv = Element::new("Move:MoveItems").with(
|
||||||
|
Element::new("Move:Move")
|
||||||
|
.with_text("Move:SrcMsgId", mv_sid)
|
||||||
|
.with_text("Move:SrcFldId", inbox.clone())
|
||||||
|
.with_text("Move:DstFldId", projekty.clone()),
|
||||||
|
);
|
||||||
|
let (_, r) = p.cmd("MoveItems", Some(mv)).await.unwrap();
|
||||||
|
let r = r.unwrap();
|
||||||
|
let resp = r.child("Move:Response").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.child_text("Move:Status").as_deref(),
|
||||||
|
Some("3"),
|
||||||
|
"{}",
|
||||||
|
r.to_xml()
|
||||||
|
);
|
||||||
|
assert!(resp
|
||||||
|
.child_text("Move:DstMsgId")
|
||||||
|
.unwrap()
|
||||||
|
.starts_with(&format!("{projekty}:")));
|
||||||
|
|
||||||
|
// ---- SendMail + Sent copy
|
||||||
|
let mime = format!("From: Jan <{USER}>\r\nTo: anna@test.local\r\nBcc: tajny@test.local\r\nSubject: E2E wysyłka\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nCześć Anno!\r\n");
|
||||||
|
let sm = Element::new("ComposeMail:SendMail")
|
||||||
|
.with_text("ComposeMail:ClientId", "c1")
|
||||||
|
.with_empty("ComposeMail:SaveInSentItems")
|
||||||
|
.with(Element::opaque("ComposeMail:MIME", mime.into_bytes()));
|
||||||
|
let (st, r) = p.cmd("SendMail", Some(sm)).await.unwrap();
|
||||||
|
assert_eq!(st, 200);
|
||||||
|
assert!(
|
||||||
|
r.is_none(),
|
||||||
|
"SendMail success returns empty body: {:?}",
|
||||||
|
r.map(|x| x.to_xml())
|
||||||
|
);
|
||||||
|
im.select("Sent").await.unwrap();
|
||||||
|
assert!(
|
||||||
|
!im.uid_search("SUBJECT \"E2E\"").await.unwrap().is_empty(),
|
||||||
|
"copy in Sent"
|
||||||
|
);
|
||||||
|
|
||||||
|
// SmartReply quoting original
|
||||||
|
let reply_src = mails[2].child_text("AirSync:ServerId").unwrap();
|
||||||
|
let mime = format!("From: {USER}\r\nTo: anna@test.local\r\nSubject: Re: test\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nOdpowiedź\r\n");
|
||||||
|
let sr = Element::new("ComposeMail:SmartReply")
|
||||||
|
.with_text("ComposeMail:ClientId", "c2")
|
||||||
|
.with(
|
||||||
|
Element::new("ComposeMail:Source")
|
||||||
|
.with_text("ComposeMail:FolderId", inbox.clone())
|
||||||
|
.with_text("ComposeMail:ItemId", reply_src),
|
||||||
|
)
|
||||||
|
.with(Element::opaque("ComposeMail:MIME", mime.into_bytes()));
|
||||||
|
let (st, _) = p.cmd("SmartReply", Some(sr)).await.unwrap();
|
||||||
|
assert_eq!(st, 200);
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
let sink = std::fs::read_dir("data/test-backends/sink")
|
||||||
|
.map(|d| d.count())
|
||||||
|
.unwrap_or(0);
|
||||||
|
assert!(sink >= 2, "SMTP sink got messages");
|
||||||
|
|
||||||
|
// ---- calendar
|
||||||
|
let (ckey, evs) = full_sync(&p, &cal).await;
|
||||||
|
let ev = evs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e.child("AirSync:ApplicationData"))
|
||||||
|
.find(|a| a.child_text("Calendar:Subject").as_deref() == Some("Spotkanie zespołu"))
|
||||||
|
.expect("seeded event");
|
||||||
|
assert_eq!(
|
||||||
|
ev.child_text("Calendar:Subject").unwrap(),
|
||||||
|
"Spotkanie zespołu"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ev.path(&["Calendar:Recurrence", "Calendar:Type"])
|
||||||
|
.unwrap()
|
||||||
|
.text_value(),
|
||||||
|
"1"
|
||||||
|
);
|
||||||
|
assert_eq!(ev.child_text("Calendar:Reminder").unwrap(), "15");
|
||||||
|
// device adds an event
|
||||||
|
let add = Element::new("AirSync:Commands").with(
|
||||||
|
Element::new("AirSync:Add")
|
||||||
|
.with_text("AirSync:ClientId", "ev-new")
|
||||||
|
.with(
|
||||||
|
Element::new("AirSync:ApplicationData")
|
||||||
|
.with_text(
|
||||||
|
"Calendar:TimeZone",
|
||||||
|
active_push::backend::tz::blob_for(&chrono_tz::Europe::Warsaw, 2026),
|
||||||
|
)
|
||||||
|
.with_text("Calendar:AllDayEvent", "0")
|
||||||
|
.with_text("Calendar:StartTime", "20261120T090000Z")
|
||||||
|
.with_text("Calendar:EndTime", "20261120T100000Z")
|
||||||
|
.with_text("Calendar:Subject", "Z telefonu")
|
||||||
|
.with_text("Calendar:Reminder", "30")
|
||||||
|
.with_text("Calendar:BusyStatus", "2"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let (ckey2, c) = changes(&p, &cal, &ckey, Some(add)).await;
|
||||||
|
let resp = c.path(&["AirSync:Responses", "AirSync:Add"]).unwrap();
|
||||||
|
assert_eq!(resp.child_text("AirSync:Status").as_deref(), Some("1"));
|
||||||
|
let new_sid = resp.child_text("AirSync:ServerId").unwrap();
|
||||||
|
// idempotent retry with the same key returns the same id
|
||||||
|
let add_again = Element::new("AirSync:Commands").with(
|
||||||
|
Element::new("AirSync:Add")
|
||||||
|
.with_text("AirSync:ClientId", "ev-new")
|
||||||
|
.with(Element::new("AirSync:ApplicationData").with_text("Calendar:Subject", "dup")),
|
||||||
|
);
|
||||||
|
let (_, c2) = changes(&p, &cal, &ckey2, Some(add_again)).await;
|
||||||
|
assert_eq!(
|
||||||
|
c2.path(&["AirSync:Responses", "AirSync:Add"])
|
||||||
|
.unwrap()
|
||||||
|
.child_text("AirSync:ServerId")
|
||||||
|
.unwrap(),
|
||||||
|
new_sid
|
||||||
|
);
|
||||||
|
// stored in Radicale
|
||||||
|
let body = reqwest::Client::new()
|
||||||
|
.request(
|
||||||
|
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
|
||||||
|
"http://127.0.0.1:15232/jan@test.local/kalendarz/",
|
||||||
|
)
|
||||||
|
.basic_auth(USER, Some(PASS))
|
||||||
|
.header("Depth", "1")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
body.matches(".ics").count() >= 3,
|
||||||
|
"new event stored on CalDAV"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- tasks + contacts
|
||||||
|
let (_, ts) = full_sync(&p, &tasks).await;
|
||||||
|
assert!(ts.iter().any(|t| t
|
||||||
|
.child("AirSync:ApplicationData")
|
||||||
|
.and_then(|a| a.child_text("Tasks:Subject"))
|
||||||
|
.as_deref()
|
||||||
|
== Some("Kupić mleko")));
|
||||||
|
let (kkey, cs) = full_sync(&p, &contacts).await;
|
||||||
|
assert_eq!(cs.len(), 1);
|
||||||
|
let c0 = cs[0].child("AirSync:ApplicationData").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
c0.child_text("Contacts:MobilePhoneNumber").unwrap(),
|
||||||
|
"+48 600 100 200"
|
||||||
|
);
|
||||||
|
let csid = cs[0].child_text("AirSync:ServerId").unwrap();
|
||||||
|
let mut changed = c0.clone();
|
||||||
|
changed.children.retain(|x| !x.is("Contacts:JobTitle"));
|
||||||
|
changed.push_text("Contacts:JobTitle", "Kierownik");
|
||||||
|
let ch = Element::new("AirSync:Commands").with(
|
||||||
|
Element::new("AirSync:Change")
|
||||||
|
.with_text("AirSync:ServerId", csid)
|
||||||
|
.with(changed),
|
||||||
|
);
|
||||||
|
let (_, c) = changes(&p, &contacts, &kkey, Some(ch)).await;
|
||||||
|
assert!(
|
||||||
|
c.child("AirSync:Responses").is_none(),
|
||||||
|
"contact change ok: {}",
|
||||||
|
c.to_xml()
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- search
|
||||||
|
let gal = Element::new("Search:Search").with(
|
||||||
|
Element::new("Search:Store")
|
||||||
|
.with_text("Search:Name", "GAL")
|
||||||
|
.with_text("Search:Query", "Nowak")
|
||||||
|
.with(Element::new("Search:Options").with_text("Search:Range", "0-9")),
|
||||||
|
);
|
||||||
|
let (_, r) = p.cmd("Search", Some(gal)).await.unwrap();
|
||||||
|
let r = r.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r.path(&["Search:Response", "Search:Store"])
|
||||||
|
.unwrap()
|
||||||
|
.child_text("Search:Total")
|
||||||
|
.as_deref(),
|
||||||
|
Some("1"),
|
||||||
|
"{}",
|
||||||
|
r.to_xml()
|
||||||
|
);
|
||||||
|
let ms = Element::new("Search:Search").with(
|
||||||
|
Element::new("Search:Store")
|
||||||
|
.with_text("Search:Name", "Mailbox")
|
||||||
|
.with(
|
||||||
|
Element::new("Search:Query").with(
|
||||||
|
Element::new("Search:And")
|
||||||
|
.with_text("AirSync:Class", "Email")
|
||||||
|
.with_text("AirSync:CollectionId", inbox.clone())
|
||||||
|
.with_text("Search:FreeText", "wiadomości numer"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with(Element::new("Search:Options").with_text("Search:Range", "0-9")),
|
||||||
|
);
|
||||||
|
let (_, r) = p.cmd("Search", Some(ms)).await.unwrap();
|
||||||
|
let r = r.unwrap();
|
||||||
|
let total: u32 = r
|
||||||
|
.path(&["Search:Response", "Search:Store"])
|
||||||
|
.unwrap()
|
||||||
|
.child_text("Search:Total")
|
||||||
|
.unwrap()
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
assert!(total >= 1, "mailbox search: {}", r.to_xml());
|
||||||
|
|
||||||
|
// ---- folder create / rename / delete
|
||||||
|
let fc = Element::new("FolderHierarchy:FolderCreate")
|
||||||
|
.with_text("FolderHierarchy:SyncKey", fkey.clone())
|
||||||
|
.with_text("FolderHierarchy:ParentId", "0")
|
||||||
|
.with_text("FolderHierarchy:DisplayName", "Nowy folder ż")
|
||||||
|
.with_text("FolderHierarchy:Type", "12");
|
||||||
|
let (_, r) = p.cmd("FolderCreate", Some(fc)).await.unwrap();
|
||||||
|
let r = r.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r.child_text("FolderHierarchy:Status").as_deref(),
|
||||||
|
Some("1"),
|
||||||
|
"{}",
|
||||||
|
r.to_xml()
|
||||||
|
);
|
||||||
|
let nkey = r.child_text("FolderHierarchy:SyncKey").unwrap();
|
||||||
|
let nid = r.child_text("FolderHierarchy:ServerId").unwrap();
|
||||||
|
let fu = Element::new("FolderHierarchy:FolderUpdate")
|
||||||
|
.with_text("FolderHierarchy:SyncKey", nkey)
|
||||||
|
.with_text("FolderHierarchy:ServerId", nid.clone())
|
||||||
|
.with_text("FolderHierarchy:ParentId", "0")
|
||||||
|
.with_text("FolderHierarchy:DisplayName", "Zmieniony");
|
||||||
|
let (_, r) = p.cmd("FolderUpdate", Some(fu)).await.unwrap();
|
||||||
|
let r = r.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r.child_text("FolderHierarchy:Status").as_deref(),
|
||||||
|
Some("1"),
|
||||||
|
"{}",
|
||||||
|
r.to_xml()
|
||||||
|
);
|
||||||
|
let fd = Element::new("FolderHierarchy:FolderDelete")
|
||||||
|
.with_text(
|
||||||
|
"FolderHierarchy:SyncKey",
|
||||||
|
r.child_text("FolderHierarchy:SyncKey").unwrap(),
|
||||||
|
)
|
||||||
|
.with_text("FolderHierarchy:ServerId", nid);
|
||||||
|
let (_, r) = p.cmd("FolderDelete", Some(fd)).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r.unwrap().child_text("FolderHierarchy:Status").as_deref(),
|
||||||
|
Some("1")
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- settings / user information
|
||||||
|
let s = Element::new("Settings:Settings")
|
||||||
|
.with(Element::new("Settings:UserInformation").with_empty("Settings:Get"));
|
||||||
|
let (_, r) = p.cmd("Settings", Some(s)).await.unwrap();
|
||||||
|
assert!(r.unwrap().to_xml().contains(USER));
|
||||||
|
|
||||||
|
// ---- ping: new mail during heartbeat
|
||||||
|
let (fresh_key, _) = changes(&p, &inbox, &ikey, None).await;
|
||||||
|
let _ = fresh_key;
|
||||||
|
let ping = Element::new("Ping:Ping")
|
||||||
|
.with_text("Ping:HeartbeatInterval", "60")
|
||||||
|
.with(
|
||||||
|
Element::new("Ping:Folders").with(
|
||||||
|
Element::new("Ping:Folder")
|
||||||
|
.with_text("Ping:Id", inbox.clone())
|
||||||
|
.with_text("Ping:Class", "Email"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let pinger = tokio::spawn({
|
||||||
|
let p2 = Probe::new(&url, USER, PASS, &dev, true).unwrap();
|
||||||
|
let key = p.policy_key.clone();
|
||||||
|
async move {
|
||||||
|
let mut p2 = p2;
|
||||||
|
p2.policy_key = key;
|
||||||
|
p2.cmd("Ping", Some(ping)).await
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
im.append(
|
||||||
|
"INBOX",
|
||||||
|
"",
|
||||||
|
b"From: x@y.pl\r\nTo: jan@test.local\r\nSubject: Push!\r\n\r\npush\r\n",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (_, r) = pinger.await.unwrap().unwrap();
|
||||||
|
let r = r.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r.child_text("Ping:Status").as_deref(),
|
||||||
|
Some("2"),
|
||||||
|
"{}",
|
||||||
|
r.to_xml()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(20),
|
||||||
|
"push latency {:?}",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
eprintln!("ping reported change after {:?}", started.elapsed());
|
||||||
|
|
||||||
|
// ---- sent folder sync works too
|
||||||
|
let (_, sent_items) = full_sync(&p, &sent).await;
|
||||||
|
assert!(!sent_items.is_empty());
|
||||||
|
let (_, trash_items) = full_sync(&p, &trash).await;
|
||||||
|
assert!(!trash_items.is_empty());
|
||||||
|
|
||||||
|
// ---- autodiscover
|
||||||
|
let base = std::env::var("AP_E2E_URL").unwrap();
|
||||||
|
let ad = reqwest::Client::new()
|
||||||
|
.post(format!("{base}/autodiscover/autodiscover.xml"))
|
||||||
|
.basic_auth(USER, Some(PASS))
|
||||||
|
.body(r#"<?xml version="1.0"?><Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/mobilesync/requestschema/2006"><Request><EMailAddress>jan@test.local</EMailAddress><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006</AcceptableResponseSchema></Request></Autodiscover>"#)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
ad.contains("<Type>MobileSync</Type>") && ad.contains("Microsoft-Server-ActiveSync"),
|
||||||
|
"{ad}"
|
||||||
|
);
|
||||||
|
eprintln!("e2e OK");
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Regenerate docs/images/*.png from a running ActivePush panel (Playwright).
|
||||||
|
// node tools/dev/screenshots.js http://127.0.0.1:9091 admin 'password' [outdir]
|
||||||
|
// With SETUP_TOKEN=<token> in the environment the first-run screen is captured and the
|
||||||
|
// administrator is created with the given credentials.
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const [base, user, pass, out = path.join(__dirname, '../../docs/images')] = process.argv.slice(2);
|
||||||
|
if (!base || !user || !pass) {
|
||||||
|
console.error('usage: node screenshots.js URL USER PASSWORD [OUTDIR]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'en-US', deviceScaleFactor: 1 });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
const shot = async (name, full = false) => {
|
||||||
|
await page.screenshot({ path: path.join(out, `${name}.png`), fullPage: full });
|
||||||
|
console.log('saved', name);
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.goto(base + '/');
|
||||||
|
await page.waitForSelector('#auth:not([hidden])');
|
||||||
|
if (process.env.SETUP_TOKEN) {
|
||||||
|
await page.fill('#setup-token', process.env.SETUP_TOKEN);
|
||||||
|
await page.fill('#username', user);
|
||||||
|
await shot('setup');
|
||||||
|
await page.fill('#password', pass);
|
||||||
|
await page.fill('#password2', pass);
|
||||||
|
} else {
|
||||||
|
await page.fill('#username', user);
|
||||||
|
await page.fill('#password', pass);
|
||||||
|
}
|
||||||
|
await page.click('#auth-submit');
|
||||||
|
await page.waitForSelector('#shell:not([hidden])');
|
||||||
|
|
||||||
|
const pages = [
|
||||||
|
['overview', true, 5000], ['live', false, 6000], ['devices', false, 2500], ['users', false, 2500],
|
||||||
|
['backends', false, 1500], ['activesync', false, 1500], ['policies', false, 1500], ['security', false, 1500],
|
||||||
|
['logs', false, 4000],
|
||||||
|
];
|
||||||
|
for (const [id, full, wait] of pages) {
|
||||||
|
await page.goto(`${base}/#${id}`);
|
||||||
|
await page.waitForTimeout(wait);
|
||||||
|
await shot(id === 'users' ? 'mailboxes' : id, full);
|
||||||
|
}
|
||||||
|
await page.goto(`${base}/#devices`);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const chips = await page.$$('.chip[data-pid]');
|
||||||
|
if (chips.length) {
|
||||||
|
await chips[0].click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await shot('device');
|
||||||
|
}
|
||||||
|
await page.goto(`${base}/#overview`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.click('#theme');
|
||||||
|
await page.waitForTimeout(4000);
|
||||||
|
await shot('overview-light');
|
||||||
|
await page.click('#theme');
|
||||||
|
|
||||||
|
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: 'en-US', deviceScaleFactor: 2, storageState: await ctx.storageState() });
|
||||||
|
const mp = await mobile.newPage();
|
||||||
|
await mp.goto(`${base}/#overview`);
|
||||||
|
await mp.waitForTimeout(4000);
|
||||||
|
await mp.screenshot({ path: path.join(out, 'mobile.png') });
|
||||||
|
console.log('saved mobile');
|
||||||
|
|
||||||
|
if (errors.length) console.error('page errors:', errors);
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local throw-away backends for development and end-to-end tests:
|
||||||
|
# Dovecot IMAP 127.0.0.1:11143 (plain, test only)
|
||||||
|
# Radicale 127.0.0.1:15232 (CalDAV + CardDAV)
|
||||||
|
# SMTP sink 127.0.0.1:12525 (prints/stores every message, no auth)
|
||||||
|
# Test account: jan@test.local / Test-Password-123
|
||||||
|
#
|
||||||
|
# NEVER use this configuration in production. Ctrl+C stops everything.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
DIR="${AP_TEST_DIR:-$ROOT/data/test-backends}"
|
||||||
|
RUN_USER="${AP_TEST_USER:-$(id -un)}"
|
||||||
|
if [[ "$RUN_USER" == "root" ]]; then
|
||||||
|
printf 'Run as a normal user or set AP_TEST_USER=<unprivileged user>.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
for bin in dovecot radicale python3; do
|
||||||
|
command -v "$bin" >/dev/null 2>&1 || { printf 'Missing %s (apt install dovecot-imapd; pip install radicale aiosmtpd)\n' "$bin" >&2; exit 127; }
|
||||||
|
done
|
||||||
|
python3 -c 'import aiosmtpd' 2>/dev/null || { printf 'Missing python aiosmtpd (pip install aiosmtpd)\n' >&2; exit 127; }
|
||||||
|
UIDN="$(id -u "$RUN_USER")"; GIDN="$(id -g "$RUN_USER")"
|
||||||
|
mkdir -p "$DIR"/{mail,run,radicale,sink}
|
||||||
|
chown -R "$RUN_USER" "$DIR" 2>/dev/null || true
|
||||||
|
|
||||||
|
cat > "$DIR/users" <<EOF
|
||||||
|
jan@test.local:{PLAIN}Test-Password-123:$UIDN:$GIDN::$DIR/mail/jan
|
||||||
|
anna@test.local:{PLAIN}Test-Password-123:$UIDN:$GIDN::$DIR/mail/anna
|
||||||
|
biuro@test.local:{PLAIN}Test-Password-123:$UIDN:$GIDN::$DIR/mail/biuro
|
||||||
|
kasia@test.local:{PLAIN}Test-Password-123:$UIDN:$GIDN::$DIR/mail/kasia
|
||||||
|
EOF
|
||||||
|
cat > "$DIR/dovecot.conf" <<EOF
|
||||||
|
protocols = imap
|
||||||
|
listen = 127.0.0.1
|
||||||
|
base_dir = $DIR/run
|
||||||
|
state_dir = $DIR/run
|
||||||
|
default_internal_user = $RUN_USER
|
||||||
|
default_login_user = $RUN_USER
|
||||||
|
default_internal_group = $(id -gn "$RUN_USER")
|
||||||
|
first_valid_uid = 1
|
||||||
|
ssl = no
|
||||||
|
disable_plaintext_auth = no
|
||||||
|
auth_mechanisms = plain login
|
||||||
|
log_path = $DIR/dovecot.log
|
||||||
|
mail_location = maildir:~/Maildir
|
||||||
|
passdb {
|
||||||
|
driver = passwd-file
|
||||||
|
args = $DIR/users
|
||||||
|
}
|
||||||
|
userdb {
|
||||||
|
driver = passwd-file
|
||||||
|
args = $DIR/users
|
||||||
|
}
|
||||||
|
service imap-login {
|
||||||
|
inet_listener imap {
|
||||||
|
address = 127.0.0.1
|
||||||
|
port = 11143
|
||||||
|
}
|
||||||
|
inet_listener imaps {
|
||||||
|
port = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service anvil {
|
||||||
|
chroot =
|
||||||
|
}
|
||||||
|
service imap-login {
|
||||||
|
chroot =
|
||||||
|
}
|
||||||
|
namespace inbox {
|
||||||
|
inbox = yes
|
||||||
|
separator = /
|
||||||
|
mailbox Drafts {
|
||||||
|
special_use = \Drafts
|
||||||
|
auto = subscribe
|
||||||
|
}
|
||||||
|
mailbox Sent {
|
||||||
|
special_use = \Sent
|
||||||
|
auto = subscribe
|
||||||
|
}
|
||||||
|
mailbox Trash {
|
||||||
|
special_use = \Trash
|
||||||
|
auto = subscribe
|
||||||
|
}
|
||||||
|
mailbox Junk {
|
||||||
|
special_use = \Junk
|
||||||
|
auto = subscribe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Radicale: htpasswd plain
|
||||||
|
for u in jan anna biuro kasia; do printf '%s@test.local:Test-Password-123\n' "$u"; done > "$DIR/radicale/users"
|
||||||
|
cat > "$DIR/radicale/config" <<EOF
|
||||||
|
[server]
|
||||||
|
hosts = 127.0.0.1:15232
|
||||||
|
[auth]
|
||||||
|
type = htpasswd
|
||||||
|
htpasswd_filename = $DIR/radicale/users
|
||||||
|
htpasswd_encryption = plain
|
||||||
|
[storage]
|
||||||
|
filesystem_folder = $DIR/radicale/collections
|
||||||
|
[rights]
|
||||||
|
type = owner_only
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$DIR/sink.py" <<'EOF'
|
||||||
|
import asyncio, sys, time, os
|
||||||
|
from aiosmtpd.controller import Controller
|
||||||
|
out = sys.argv[1]
|
||||||
|
class H:
|
||||||
|
async def handle_DATA(self, server, session, envelope):
|
||||||
|
name = os.path.join(out, f"{time.time():.6f}.eml")
|
||||||
|
with open(name, "wb") as f:
|
||||||
|
f.write(f"X-Envelope-From: {envelope.mail_from}\r\nX-Envelope-To: {', '.join(envelope.rcpt_tos)}\r\n".encode())
|
||||||
|
f.write(envelope.content)
|
||||||
|
print(f"[smtp-sink] {envelope.mail_from} -> {envelope.rcpt_tos} ({len(envelope.content)} bytes) saved {name}", flush=True)
|
||||||
|
return "250 OK"
|
||||||
|
c = Controller(H(), hostname="127.0.0.1", port=12525)
|
||||||
|
c.start()
|
||||||
|
print("[smtp-sink] listening on 127.0.0.1:12525", flush=True)
|
||||||
|
try:
|
||||||
|
asyncio.new_event_loop().run_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
EOF
|
||||||
|
|
||||||
|
pids=()
|
||||||
|
cleanup() { for p in "${pids[@]}"; do kill "$p" 2>/dev/null || true; done; dovecot -c "$DIR/dovecot.conf" stop 2>/dev/null || true; }
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
dovecot -c "$DIR/dovecot.conf"
|
||||||
|
if [[ "$(id -un)" == "$RUN_USER" ]]; then
|
||||||
|
radicale -C "$DIR/radicale/config" >"$DIR/radicale.log" 2>&1 & pids+=($!)
|
||||||
|
python3 "$DIR/sink.py" "$DIR/sink" & pids+=($!)
|
||||||
|
else
|
||||||
|
su -s /bin/bash "$RUN_USER" -c "radicale -C '$DIR/radicale/config' >'$DIR/radicale.log' 2>&1" & pids+=($!)
|
||||||
|
su -s /bin/bash "$RUN_USER" -c "python3 '$DIR/sink.py' '$DIR/sink'" & pids+=($!)
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
cat <<EOF
|
||||||
|
Test backends running (data in $DIR):
|
||||||
|
IMAP 127.0.0.1:11143 (security: none)
|
||||||
|
CalDAV http://127.0.0.1:15232 path /%u/
|
||||||
|
CardDAV http://127.0.0.1:15232 path /%u/
|
||||||
|
SMTP 127.0.0.1:12525 (auth: none, security: none) -> $DIR/sink
|
||||||
|
accounts jan, anna, biuro, kasia @test.local, password Test-Password-123
|
||||||
|
Apply matching settings with: ./dev.sh config apply tools/dev/test-settings.json
|
||||||
|
Press Ctrl+C to stop.
|
||||||
|
EOF
|
||||||
|
wait
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"general": { "server_name": "ActivePush DEV", "default_timezone": "Europe/Warsaw", "login_format": "as_given", "default_domain": "test.local", "email_template": "{login}" },
|
||||||
|
"imap": { "host": "127.0.0.1", "port": 11143, "security": "none", "verify_tls": false },
|
||||||
|
"smtp": { "method": "smtp", "host": "127.0.0.1", "port": 12525, "security": "none", "auth": "none", "verify_tls": false },
|
||||||
|
"caldav": { "enabled": true, "base_url": "http://127.0.0.1:15232", "path": "/%u/", "verify_tls": false },
|
||||||
|
"carddav": { "enabled": true, "base_url": "http://127.0.0.1:15232", "path": "/%u/", "verify_tls": false, "gal_min_length": 2 },
|
||||||
|
"ping": { "check_interval_secs": 5, "lifetime_min": 30, "lifetime_max": 3540, "max_folders": 300 },
|
||||||
|
"sync": { "heartbeat_min": 30 },
|
||||||
|
"security": { "auth_fail_max": 20 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Convert an existing Z-Push installation's configuration into ActivePush settings JSON.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 zpush-import.py /usr/share/z-push # source layout (config.php, backend/*/config.php)
|
||||||
|
python3 zpush-import.py /etc/z-push # Debian package layout (*.conf.php)
|
||||||
|
python3 zpush-import.py /var/www/z-push > zpush.json
|
||||||
|
active-push config apply zpush.json
|
||||||
|
|
||||||
|
Only settings are converted. Device sync states are not portable: devices notice the new
|
||||||
|
server (unknown sync key / policy key) and resynchronise automatically.
|
||||||
|
"""
|
||||||
|
import configparser
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
DEFINE = re.compile(r"""^\s*define\s*\(\s*['"]([A-Z0-9_]+)['"]\s*,\s*(.+?)\)\s*;""", re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def php_value(raw):
|
||||||
|
raw = raw.strip()
|
||||||
|
if raw.startswith(("'", '"')) and raw.endswith(raw[0]):
|
||||||
|
return raw[1:-1].replace("\\'", "'").replace('\\"', '"')
|
||||||
|
low = raw.lower()
|
||||||
|
if low in ("true", "false"):
|
||||||
|
return low == "true"
|
||||||
|
if re.fullmatch(r"-?\d+", raw):
|
||||||
|
return int(raw)
|
||||||
|
if low.startswith("array(") or raw.startswith("["):
|
||||||
|
inner = raw[raw.index("(") + 1: raw.rindex(")")] if low.startswith("array(") else raw[1:-1]
|
||||||
|
out = {}
|
||||||
|
for k, v in re.findall(r"""['"]([^'"]+)['"]\s*=>\s*('[^']*'|"[^"]*"|[^,\)]+)""", inner):
|
||||||
|
out[k] = php_value(v)
|
||||||
|
return out
|
||||||
|
if low.startswith("serialize("):
|
||||||
|
return None
|
||||||
|
return {"__const__": raw}
|
||||||
|
|
||||||
|
|
||||||
|
def read_defines(paths):
|
||||||
|
defs = {}
|
||||||
|
for p in paths:
|
||||||
|
text = p.read_text(errors="replace")
|
||||||
|
# strip block comments and line comments
|
||||||
|
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
|
||||||
|
lines = [l for l in text.splitlines() if not l.strip().startswith(("//", "#"))]
|
||||||
|
joined = "\n".join(lines)
|
||||||
|
for m in re.finditer(r"define\s*\(.*?\)\s*;", joined, re.S):
|
||||||
|
d = DEFINE.match(m.group(0))
|
||||||
|
if d:
|
||||||
|
defs[d.group(1)] = php_value(d.group(2))
|
||||||
|
return defs
|
||||||
|
|
||||||
|
|
||||||
|
def find_configs(root):
|
||||||
|
root = pathlib.Path(root)
|
||||||
|
files = []
|
||||||
|
for pat in ["config.php", "*.conf.php", "backend/*/config.php", "autodiscover/config.php", "src/config.php", "src/backend/*/config.php", "src/autodiscover/config.php"]:
|
||||||
|
files += [f for f in root.glob(pat) if f.is_file()]
|
||||||
|
policies = [f for f in [root / "policies.ini", root / "src/policies.ini"] if f.is_file()]
|
||||||
|
return sorted(set(files)), policies
|
||||||
|
|
||||||
|
|
||||||
|
FILTER = {"SYNC_FILTERTYPE_ALL": 0, "SYNC_FILTERTYPE_1DAY": 1, "SYNC_FILTERTYPE_3DAYS": 2, "SYNC_FILTERTYPE_1WEEK": 3,
|
||||||
|
"SYNC_FILTERTYPE_2WEEKS": 4, "SYNC_FILTERTYPE_1MONTH": 5, "SYNC_FILTERTYPE_3MONTHS": 6, "SYNC_FILTERTYPE_6MONTHS": 7}
|
||||||
|
|
||||||
|
POLICY_KEYS = {
|
||||||
|
"devpwenabled": bool, "alphanumpwreq": bool, "devencenabled": bool, "attenabled": bool, "mindevpwlenngth": int,
|
||||||
|
"maxinacttimedevlock": int, "maxdevpwfailedattempts": int, "maxattsize": int, "allowsimpledevpw": bool,
|
||||||
|
"devpwexpiration": int, "devpwhistory": int, "allowstoragecard": bool, "allowcam": bool, "reqdevenc": bool,
|
||||||
|
"allowunsignedapps": bool, "mindevcomplexchars": int, "allowwifi": bool, "allowtextmessaging": bool,
|
||||||
|
"allowpopimapemail": bool, "allowbluetooth": int, "allowirda": bool, "reqmansyncroam": bool, "maxcalagefilter": int,
|
||||||
|
"allowhtmlemail": bool, "maxemailagefilter": int, "maxemailbodytruncsize": int, "maxemailhtmlbodytruncsize": int,
|
||||||
|
"reqsignedsmimemessages": bool, "reqencsmimemessages": bool, "reqsignedsmimealgorithm": int,
|
||||||
|
"reqencsmimealgorithm": int, "allowsmimeencalgneg": int, "allowsmimesoftcerts": bool, "allowbrowser": bool,
|
||||||
|
"allowconsumeremail": bool, "allowinternetsharing": bool, "allowremotedesktop": bool, "allowdesktopsync": bool,
|
||||||
|
"allowunsignedinstallationpackages": bool, "pwrecoveryenabled": bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_policies(path):
|
||||||
|
cp = configparser.ConfigParser(interpolation=None, strict=False)
|
||||||
|
cp.read_string(path.read_text(errors="replace"))
|
||||||
|
out = []
|
||||||
|
for sec in cp.sections():
|
||||||
|
p = {"name": sec}
|
||||||
|
for k, typ in POLICY_KEYS.items():
|
||||||
|
if k in cp[sec]:
|
||||||
|
v = cp[sec][k].strip().strip("'\"")
|
||||||
|
if v == "":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
p[k] = (int(v) != 0) if typ is bool else int(v)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(__doc__, file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
files, pol_files = find_configs(sys.argv[1])
|
||||||
|
if not files:
|
||||||
|
sys.exit(f"no Z-Push configuration files found in {sys.argv[1]}")
|
||||||
|
d = read_defines(files)
|
||||||
|
notes = [f"read {f}" for f in files]
|
||||||
|
s = {"general": {}, "imap": {}, "smtp": {}, "caldav": {}, "carddav": {}, "sync": {}, "ping": {}, "provisioning": {}, "autodiscover": {}}
|
||||||
|
|
||||||
|
if isinstance(d.get("TIMEZONE"), str) and d["TIMEZONE"]:
|
||||||
|
s["general"]["default_timezone"] = d["TIMEZONE"]
|
||||||
|
if "USE_FULLEMAIL_FOR_LOGIN" in d:
|
||||||
|
s["general"]["login_format"] = "as_given" if d["USE_FULLEMAIL_FOR_LOGIN"] else "local_part"
|
||||||
|
|
||||||
|
# IMAP
|
||||||
|
if "IMAP_SERVER" in d:
|
||||||
|
s["imap"]["host"] = d["IMAP_SERVER"]
|
||||||
|
if "IMAP_PORT" in d:
|
||||||
|
s["imap"]["port"] = int(d["IMAP_PORT"])
|
||||||
|
opts = str(d.get("IMAP_OPTIONS", "")).lower()
|
||||||
|
if opts:
|
||||||
|
if "/ssl" in opts:
|
||||||
|
s["imap"]["security"] = "tls"
|
||||||
|
elif "/tls" in opts:
|
||||||
|
s["imap"]["security"] = "start_tls"
|
||||||
|
elif "/notls" in opts:
|
||||||
|
s["imap"]["security"] = "none"
|
||||||
|
s["imap"]["verify_tls"] = "novalidate-cert" not in opts
|
||||||
|
for zk, ak in [("IMAP_FOLDER_PREFIX", "folder_prefix"), ("IMAP_FOLDER_INBOX", "folder_inbox"), ("IMAP_FOLDER_SENT", "folder_sent"),
|
||||||
|
("IMAP_FOLDER_DRAFT", "folder_drafts"), ("IMAP_FOLDER_TRASH", "folder_trash"), ("IMAP_FOLDER_SPAM", "folder_spam"),
|
||||||
|
("IMAP_FOLDER_ARCHIVE", "folder_archive")]:
|
||||||
|
if isinstance(d.get(zk), str):
|
||||||
|
s["imap"][ak] = d[zk]
|
||||||
|
if "IMAP_AUTOSEEN_ON_DELETE" in d:
|
||||||
|
s["imap"]["autoseen_on_delete"] = bool(d["IMAP_AUTOSEEN_ON_DELETE"])
|
||||||
|
if "IMAP_INLINE_FORWARD" in d:
|
||||||
|
s["imap"]["inline_forward"] = bool(d["IMAP_INLINE_FORWARD"])
|
||||||
|
if isinstance(d.get("IMAP_EXCLUDED_FOLDERS"), str) and d["IMAP_EXCLUDED_FOLDERS"]:
|
||||||
|
s["imap"]["excluded_folders"] = [x for x in d["IMAP_EXCLUDED_FOLDERS"].split("|") if x]
|
||||||
|
df = d.get("IMAP_DEFAULTFROM")
|
||||||
|
if isinstance(df, str):
|
||||||
|
if df.startswith("@"):
|
||||||
|
s["general"]["email_template"] = "{local}" + df
|
||||||
|
elif df == "domain":
|
||||||
|
s["general"]["email_template"] = "{local}@{domain}"
|
||||||
|
elif df in ("", "username"):
|
||||||
|
s["general"]["email_template"] = "{login}"
|
||||||
|
elif df in ("sql", "ldap"):
|
||||||
|
notes.append("IMAP_DEFAULTFROM sql/ldap is not supported; using {login} - adjust email_template")
|
||||||
|
|
||||||
|
# SMTP
|
||||||
|
method = str(d.get("IMAP_SMTP_METHOD", "")).lower()
|
||||||
|
if method in ("mail", "sendmail"):
|
||||||
|
s["smtp"]["method"] = "sendmail"
|
||||||
|
notes.append("SMTP method 'mail'/'sendmail' -> sendmail (consider SMTP submission on port 587 instead)")
|
||||||
|
elif method == "smtp":
|
||||||
|
s["smtp"]["method"] = "smtp"
|
||||||
|
params = d.get("IMAP_SMTP_PARAMS") if isinstance(d.get("IMAP_SMTP_PARAMS"), dict) else {}
|
||||||
|
host = str(params.get("host", "localhost"))
|
||||||
|
if host.startswith("ssl://") or host.startswith("tls://"):
|
||||||
|
s["smtp"]["security"] = "tls"
|
||||||
|
host = host.split("://", 1)[1]
|
||||||
|
s["smtp"]["host"] = host
|
||||||
|
s["smtp"]["port"] = int(params.get("port", 25))
|
||||||
|
if s["smtp"]["port"] == 587:
|
||||||
|
s["smtp"]["security"] = "start_tls"
|
||||||
|
if params.get("auth"):
|
||||||
|
if params.get("username") == "imap_username":
|
||||||
|
s["smtp"]["auth"] = "user_credentials"
|
||||||
|
else:
|
||||||
|
s["smtp"]["auth"] = "static"
|
||||||
|
s["smtp"]["username"] = str(params.get("username", ""))
|
||||||
|
s["smtp"]["password"] = str(params.get("password", ""))
|
||||||
|
else:
|
||||||
|
s["smtp"]["auth"] = "none"
|
||||||
|
if params.get("verify_peer") is False:
|
||||||
|
s["smtp"]["verify_tls"] = False
|
||||||
|
|
||||||
|
# CalDAV / CardDAV
|
||||||
|
def dav(prefix, key):
|
||||||
|
server = d.get(f"{prefix}_SERVER")
|
||||||
|
if not isinstance(server, str) or server in ("", "caldavserver.domain.com"):
|
||||||
|
return
|
||||||
|
proto = d.get(f"{prefix}_PROTOCOL", "https")
|
||||||
|
port = d.get(f"{prefix}_PORT", 443 if proto == "https" else 80)
|
||||||
|
default_port = (proto == "https" and str(port) == "443") or (proto == "http" and str(port) == "80")
|
||||||
|
s[key]["enabled"] = True
|
||||||
|
s[key]["base_url"] = f"{proto}://{server}" + ("" if default_port else f":{port}")
|
||||||
|
if isinstance(d.get(f"{prefix}_PATH"), str):
|
||||||
|
s[key]["path"] = d[f"{prefix}_PATH"]
|
||||||
|
if f"{prefix}_PATH_INCLUDE" in d and isinstance(d[f"{prefix}_PATH_INCLUDE"], str):
|
||||||
|
s[key]["include_regex"] = d[f"{prefix}_PATH_INCLUDE"].strip("/")
|
||||||
|
if f"{prefix}_PATH_EXCLUDE" in d and isinstance(d[f"{prefix}_PATH_EXCLUDE"], str):
|
||||||
|
s[key]["exclude_regex"] = d[f"{prefix}_PATH_EXCLUDE"].strip("/")
|
||||||
|
ssl = d.get("DAV_SSL_VERIFY")
|
||||||
|
if isinstance(ssl, dict) and "NONE" in ssl.get("__const__", ""):
|
||||||
|
s[key]["verify_tls"] = False
|
||||||
|
dav("CALDAV", "caldav")
|
||||||
|
dav("CARDDAV", "carddav")
|
||||||
|
if isinstance(d.get("CARDDAV_GAL_PATH"), str):
|
||||||
|
s["carddav"]["gal_path"] = d["CARDDAV_GAL_PATH"]
|
||||||
|
if isinstance(d.get("CARDDAV_GAL_MIN_LENGTH"), int):
|
||||||
|
s["carddav"]["gal_min_length"] = d["CARDDAV_GAL_MIN_LENGTH"]
|
||||||
|
if isinstance(d.get("CARDDAV_DEFAULT_PATH"), str):
|
||||||
|
s["carddav"]["default_collection"] = d["CARDDAV_DEFAULT_PATH"].rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
# sync / ping / provisioning
|
||||||
|
if isinstance(d.get("SYNC_MAX_ITEMS"), int):
|
||||||
|
s["sync"]["max_items"] = d["SYNC_MAX_ITEMS"]
|
||||||
|
ft = d.get("SYNC_FILTERTIME_MAX")
|
||||||
|
if isinstance(ft, dict) and ft.get("__const__") in FILTER:
|
||||||
|
s["sync"]["filter_time_max"] = FILTER[ft["__const__"]]
|
||||||
|
sc = d.get("SYNC_CONFLICT_DEFAULT")
|
||||||
|
if isinstance(sc, dict):
|
||||||
|
s["sync"]["server_wins"] = "OVERWRITE_PIM" in sc["__const__"]
|
||||||
|
for zk, sec, ak in [("PING_INTERVAL", "ping", "check_interval_secs"), ("PING_LOWER_BOUND_LIFETIME", "ping", "lifetime_min"),
|
||||||
|
("PING_HIGHER_BOUND_LIFETIME", "ping", "lifetime_max"), ("RETRY_AFTER_DELAY", "sync", "retry_after_secs"),
|
||||||
|
("SEARCH_WAIT", "sync", "search_wait_secs"), ("SEARCH_MAXRESULTS", "sync", "search_max_results")]:
|
||||||
|
if isinstance(d.get(zk), int) and not isinstance(d.get(zk), bool):
|
||||||
|
s[sec][ak] = d[zk]
|
||||||
|
if isinstance(d.get("SYNC_CONTACTS_MAXPICTURESIZE"), int):
|
||||||
|
s["carddav"]["max_picture_kb"] = max(1, d["SYNC_CONTACTS_MAXPICTURESIZE"] // 1024)
|
||||||
|
if "PROVISIONING" in d:
|
||||||
|
s["provisioning"]["enabled"] = bool(d["PROVISIONING"])
|
||||||
|
if "LOOSE_PROVISIONING" in d:
|
||||||
|
s["provisioning"]["loose"] = bool(d["LOOSE_PROVISIONING"])
|
||||||
|
if pol_files:
|
||||||
|
pols = parse_policies(pol_files[0])
|
||||||
|
if pols:
|
||||||
|
s["provisioning"]["policies"] = pols
|
||||||
|
s["provisioning"]["default_policy"] = "default" if any(p["name"] == "default" for p in pols) else pols[0]["name"]
|
||||||
|
notes.append(f"imported {len(pols)} polic{'y' if len(pols) == 1 else 'ies'} from {pol_files[0]}")
|
||||||
|
if isinstance(d.get("ZPUSH_HOST"), str) and d["ZPUSH_HOST"]:
|
||||||
|
s["autodiscover"]["server_url"] = f"https://{d['ZPUSH_HOST']}/Microsoft-Server-ActiveSync"
|
||||||
|
|
||||||
|
s = {k: v for k, v in s.items() if v}
|
||||||
|
for n in notes:
|
||||||
|
print(f"# {n}", file=sys.stderr)
|
||||||
|
print(json.dumps(s, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,931 @@
|
|||||||
|
'use strict';
|
||||||
|
(() => {
|
||||||
|
// ------------------------------------------------------------------ basics
|
||||||
|
const $ = (s, r = document) => r.querySelector(s);
|
||||||
|
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
|
||||||
|
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
|
||||||
|
const store = {
|
||||||
|
get(k, d) { try { return localStorage.getItem(k) ?? d; } catch { return d; } },
|
||||||
|
set(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } },
|
||||||
|
};
|
||||||
|
let LANG = store.get('ap.lang', 'en');
|
||||||
|
const T = (pl, en) => (LANG === 'pl' ? pl : en);
|
||||||
|
|
||||||
|
const S = { session: null, csrf: '', settings: null, timezones: [], timers: [], sse: null, page: 'overview', devices: [] };
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
S.timers.forEach((t) => clearInterval(t));
|
||||||
|
S.timers = [];
|
||||||
|
if (S.sse) { S.sse.close(); S.sse = null; }
|
||||||
|
}
|
||||||
|
function every(ms, fn) { fn(); S.timers.push(setInterval(fn, ms)); }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ formatting
|
||||||
|
const fmtNum = (n) => (n == null ? '-' : Number(n).toLocaleString(LANG === 'pl' ? 'pl-PL' : 'en-US'));
|
||||||
|
function fmtBytes(b) {
|
||||||
|
b = Number(b || 0);
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0;
|
||||||
|
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
|
||||||
|
return `${b.toFixed(b < 10 && i ? 1 : 0)} ${u[i]}`;
|
||||||
|
}
|
||||||
|
function fmtDur(s) {
|
||||||
|
s = Math.max(0, Math.floor(s));
|
||||||
|
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
||||||
|
if (d) return `${d}d ${h}h`;
|
||||||
|
if (h) return `${h}h ${m}m`;
|
||||||
|
if (m) return `${m}m ${s % 60}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
const fmtTs = (t) => (t ? new Date(t * 1000).toLocaleString(LANG === 'pl' ? 'pl-PL' : 'en-GB') : '-');
|
||||||
|
const fmtTime = (t) => (t ? new Date(t * 1000).toLocaleTimeString(LANG === 'pl' ? 'pl-PL' : 'en-GB') : '-');
|
||||||
|
function ago(t) {
|
||||||
|
if (!t) return T('nigdy', 'never');
|
||||||
|
const s = Math.floor(Date.now() / 1000) - t;
|
||||||
|
if (s < 60) return T(`${s}s temu`, `${s}s ago`);
|
||||||
|
return T(`${fmtDur(s)} temu`, `${fmtDur(s)} ago`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ api
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const opt = { method, credentials: 'same-origin', headers: {} };
|
||||||
|
if (method !== 'GET') opt.headers['X-CSRF-Token'] = S.csrf;
|
||||||
|
if (body !== undefined) {
|
||||||
|
opt.headers['Content-Type'] = 'application/json';
|
||||||
|
opt.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const r = await fetch(path, opt);
|
||||||
|
if (r.status === 401 && !path.startsWith('/api/login') && !path.startsWith('/api/setup')) {
|
||||||
|
showAuth();
|
||||||
|
throw new Error(T('Sesja wygasła', 'Session expired'));
|
||||||
|
}
|
||||||
|
const ct = r.headers.get('content-type') || '';
|
||||||
|
const data = ct.includes('json') ? await r.json() : await r.text();
|
||||||
|
if (!r.ok) throw new Error((data && data.error) || `HTTP ${r.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastT;
|
||||||
|
function toast(msg, error) {
|
||||||
|
const t = $('#toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.toggle('error', !!error);
|
||||||
|
t.hidden = false;
|
||||||
|
clearTimeout(toastT);
|
||||||
|
toastT = setTimeout(() => { t.hidden = true; }, error ? 6000 : 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWidths(root) {
|
||||||
|
$$('[data-w]', root).forEach((el) => { el.style.width = `${Math.max(0, Math.min(100, Number(el.dataset.w)))}%`; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ charts (SVG)
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
function niceMax(v) {
|
||||||
|
if (v <= 0) return 1;
|
||||||
|
const p = 10 ** Math.floor(Math.log10(v));
|
||||||
|
for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p;
|
||||||
|
return 10 * p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line/area chart. series: [{name, cls:'k1', values:[...] }], labels: [string] */
|
||||||
|
function lineChart(el, { labels, series, height = 170, area = true, fmt = fmtNum, bars = false, min = 1 }) {
|
||||||
|
const W = Math.max(el.clientWidth || 600, 280), H = height, L = 46, R = 22, Tp = 8, B = 20;
|
||||||
|
const n = labels.length;
|
||||||
|
const max = niceMax(Math.max(min, ...series.flatMap((s) => s.values)));
|
||||||
|
const slot = (W - L - R) / Math.max(1, n);
|
||||||
|
const x = bars ? (i) => L + slot * (i + 0.5) : (i) => L + (n <= 1 ? 0 : (i * (W - L - R)) / (n - 1));
|
||||||
|
const y = (v) => Tp + (H - Tp - B) * (1 - v / max);
|
||||||
|
let svg = `<svg class="chart" viewBox="0 0 ${W} ${H}" width="100%" height="${H}" preserveAspectRatio="none">`;
|
||||||
|
for (let g = 0; g <= 4; g++) {
|
||||||
|
const v = (max * g) / 4, yy = y(v);
|
||||||
|
svg += `<line class="grid-line" x1="${L}" x2="${W - R}" y1="${yy}" y2="${yy}"/><text x="${L - 6}" y="${yy + 3}" text-anchor="end">${esc(fmt(Math.round(v * 100) / 100))}</text>`;
|
||||||
|
}
|
||||||
|
const step = Math.max(1, Math.ceil(n / 8));
|
||||||
|
for (let i = 0; i < n; i += step) {
|
||||||
|
const anchor = !bars && n > 1 && i === 0 ? 'start' : (x(i) > W - R - 30 ? 'end' : 'middle');
|
||||||
|
svg += `<text x="${x(i)}" y="${H - 5}" text-anchor="${anchor}">${esc(labels[i])}</text>`;
|
||||||
|
}
|
||||||
|
if (bars) {
|
||||||
|
const bw = Math.min(26, Math.max(1, (slot * 0.7) / series.length));
|
||||||
|
series.forEach((s, si) => {
|
||||||
|
s.values.forEach((v, i) => {
|
||||||
|
const xx = x(i) - (bw * series.length) / 2 + si * bw;
|
||||||
|
svg += `<rect class="br ${s.cls}" x="${xx}" y="${y(v)}" width="${bw}" height="${Math.max(0, y(0) - y(v))}" rx="1"/>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
series.forEach((s) => {
|
||||||
|
const pts = s.values.map((v, i) => `${x(i)},${y(v)}`).join(' ');
|
||||||
|
if (area && n > 1) svg += `<polygon class="ar ${s.cls}" points="${x(0)},${y(0)} ${pts} ${x(n - 1)},${y(0)}"/>`;
|
||||||
|
svg += `<polyline class="ln ${s.cls}" points="${pts}"/>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
svg += `<line class="cursor" id="cur" x1="0" x2="0" y1="${Tp}" y2="${H - B}" visibility="hidden"/></svg>`;
|
||||||
|
el.innerHTML = `<div class="chart-wrap">${svg}<div class="chart-tip" hidden></div></div>
|
||||||
|
<div class="legend">${series.map((s) => `<span><i class="sw ${s.cls}"></i>${esc(s.name)}</span>`).join('')}</div>`;
|
||||||
|
const wrap = $('.chart-wrap', el), tip = $('.chart-tip', el), cur = $('#cur', el), svgEl = $('svg', el);
|
||||||
|
wrap.addEventListener('mousemove', (e) => {
|
||||||
|
const rect = svgEl.getBoundingClientRect();
|
||||||
|
const px = ((e.clientX - rect.left) / rect.width) * W;
|
||||||
|
const i = Math.max(0, Math.min(n - 1, bars ? Math.floor((px - L) / slot) : Math.round(((px - L) / (W - L - R)) * (n - 1))));
|
||||||
|
cur.setAttribute('x1', x(i)); cur.setAttribute('x2', x(i)); cur.setAttribute('visibility', 'visible');
|
||||||
|
tip.innerHTML = `<b>${esc(labels[i])}</b><br>${series.map((s) => `${esc(s.name)}: ${esc(fmt(s.values[i]))}`).join('<br>')}`;
|
||||||
|
tip.hidden = false;
|
||||||
|
const left = Math.min(e.clientX - rect.left + 12, rect.width - tip.offsetWidth - 4);
|
||||||
|
tip.style.left = `${Math.max(0, left)}px`;
|
||||||
|
tip.style.top = '6px';
|
||||||
|
});
|
||||||
|
wrap.addEventListener('mouseleave', () => { tip.hidden = true; cur.setAttribute('visibility', 'hidden'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function spark(values, cls = 'k1') {
|
||||||
|
const n = values.length;
|
||||||
|
if (n < 2) return '';
|
||||||
|
const max = Math.max(1, ...values);
|
||||||
|
const pts = values.map((v, i) => `${(i * 100) / (n - 1)},${30 - (v / max) * 28}`).join(' ');
|
||||||
|
return `<svg class="spark" viewBox="0 0 100 32" preserveAspectRatio="none"><polygon class="ar ${cls}" points="0,32 ${pts} 100,32"/><polyline class="ln ${cls}" points="${pts}"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function donut(el, items, center) {
|
||||||
|
const total = items.reduce((a, b) => a + b.value, 0) || 1;
|
||||||
|
const r = 48, C = 2 * Math.PI * r;
|
||||||
|
let off = 0, arcs = '';
|
||||||
|
items.forEach((it, i) => {
|
||||||
|
const len = (it.value / total) * C;
|
||||||
|
arcs += `<circle class="dn k${(i % 7) + 1}" r="${r}" cx="60" cy="60" stroke-dasharray="${len} ${C - len}" stroke-dashoffset="${-off}" transform="rotate(-90 60 60)"><title>${esc(it.label)}: ${it.value}</title></circle>`;
|
||||||
|
off += len;
|
||||||
|
});
|
||||||
|
el.innerHTML = `<div class="donut-layout"><svg viewBox="0 0 120 120" width="130" height="130">${arcs}<text class="donut-center" x="60" y="65" text-anchor="middle">${esc(center ?? total)}</text></svg>
|
||||||
|
<div>${items.map((it, i) => `<div class="legend"><span><i class="sw k${(i % 7) + 1}"></i>${esc(it.label)} - <b>${fmtNum(it.value)}</b></span></div>`).join('') || `<div class="empty">${T('Brak danych', 'No data')}</div>`}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hbars(items, cls = 'k1') {
|
||||||
|
const max = Math.max(1, ...items.map((i) => i.value));
|
||||||
|
return items.map((i) => `<div class="hbar"><span title="${esc(i.label)}">${esc(i.label)}</span><div class="track"><div class="fill ${cls}" data-w="${(i.value / max) * 100}"></div></div><span class="n">${fmtNum(i.value)}</span></div>`).join('')
|
||||||
|
|| `<div class="empty">${T('Brak danych', 'No data')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ modal
|
||||||
|
function modal(html, cls = '') {
|
||||||
|
const root = $('#modal-root');
|
||||||
|
root.innerHTML = `<div class="backdrop"><div class="dialog ${cls}" role="dialog" aria-modal="true">${html}</div></div>`;
|
||||||
|
const bd = $('.backdrop', root);
|
||||||
|
bd.addEventListener('mousedown', (e) => { if (e.target === bd) closeModal(); });
|
||||||
|
$$('[data-close]', root).forEach((b) => b.addEventListener('click', closeModal));
|
||||||
|
document.addEventListener('keydown', escClose);
|
||||||
|
applyWidths(root);
|
||||||
|
return $('.dialog', root);
|
||||||
|
}
|
||||||
|
function escClose(e) { if (e.key === 'Escape') closeModal(); }
|
||||||
|
function closeModal() { $('#modal-root').innerHTML = ''; document.removeEventListener('keydown', escClose); }
|
||||||
|
|
||||||
|
function confirmBox(title, text, danger, typed) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const d = modal(`<div class="dialog-head"><h2>${esc(title)}</h2><button class="quiet" data-close>✕</button></div>
|
||||||
|
<p class="fine">${esc(text)}</p>
|
||||||
|
${typed ? `<label>${T('Wpisz', 'Type')} <b class="mono">${esc(typed)}</b> ${T('aby potwierdzić', 'to confirm')}</label><input id="cf-typed" autocomplete="off">` : ''}
|
||||||
|
<div class="form-actions"><button class="quiet" data-close>${T('Anuluj', 'Cancel')}</button><button class="${danger ? 'danger' : 'primary'}" id="cf-ok">${T('Potwierdź', 'Confirm')}</button></div>`, 'small');
|
||||||
|
$('#cf-ok', d).addEventListener('click', () => {
|
||||||
|
if (typed && $('#cf-typed', d).value.trim() !== typed) { toast(T('Wpisany tekst się nie zgadza', 'Text does not match'), true); return; }
|
||||||
|
closeModal(); resolve(true);
|
||||||
|
});
|
||||||
|
$$('[data-close]', d).forEach((b) => b.addEventListener('click', () => resolve(false)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ navigation
|
||||||
|
const NAV = [
|
||||||
|
['overview', T('Przegląd', 'Overview')],
|
||||||
|
['live', T('Na żywo', 'Live')],
|
||||||
|
['devices', T('Urządzenia', 'Devices')],
|
||||||
|
['users', T('Skrzynki', 'Mailboxes')],
|
||||||
|
['backends', T('Backendy', 'Backends')],
|
||||||
|
['activesync', 'ActiveSync'],
|
||||||
|
['policies', T('Polityki', 'Policies')],
|
||||||
|
['security', T('Bezpieczeństwo', 'Security')],
|
||||||
|
['logs', T('Logi i audyt', 'Logs & audit')],
|
||||||
|
['about', T('O programie', 'About')],
|
||||||
|
];
|
||||||
|
const navLabel = (id) => (NAV.find((n) => n[0] === id) || NAV[0])[1];
|
||||||
|
|
||||||
|
function renderNav() {
|
||||||
|
$('#nav').innerHTML = NAV.map((n, i) => `<button class="nav${S.page === n[0] ? ' active' : ''}" data-page="${n[0]}"><span>${String(i + 1).padStart(2, '0')}</span><b>${esc(n[1])}</b>${n[0] === 'devices' ? '<i class="count" id="nav-dev-count"></i>' : ''}</button>`).join('');
|
||||||
|
$$('#nav .nav').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.page; $('#nav').classList.remove('open'); }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function go() {
|
||||||
|
const id = (location.hash || '#overview').slice(1).split('/')[0];
|
||||||
|
S.page = PAGES[id] ? id : 'overview';
|
||||||
|
clearTimers();
|
||||||
|
closeModal();
|
||||||
|
renderNav();
|
||||||
|
$('#page-label').textContent = navLabel(S.page);
|
||||||
|
const v = $('#view');
|
||||||
|
v.innerHTML = '';
|
||||||
|
PAGES[S.page](v).catch((e) => { v.innerHTML = `<div class="notice">${esc(e.message)}</div>`; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function head(eyebrow, title, desc, actions = '') {
|
||||||
|
return `<div class="page-head"><div><div class="small-label eyebrow">${esc(eyebrow)}</div><h1>${esc(title)}</h1>${desc ? `<p class="fine">${esc(desc)}</p>` : ''}</div><div class="top-actions">${actions}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ pages
|
||||||
|
const PAGES = {};
|
||||||
|
|
||||||
|
PAGES.overview = async (v) => {
|
||||||
|
v.innerHTML = head(T('PANEL STEROWANIA', 'CONTROL ROOM'), T('Przegląd', 'Overview'), T('Ruch ActiveSync, aktywne połączenia push, urządzenia i stan backendów.', 'ActiveSync traffic, push connections, devices and backend state.'), `<select id="range" class="quiet"><option value="60">60 min</option><option value="360">6 h</option><option value="1440">24 h</option></select>`) +
|
||||||
|
`<div class="grid stats" id="tiles"></div>
|
||||||
|
<div class="grid two">
|
||||||
|
<div class="card"><div class="card-heading"><div><span class="small-label">${T('ŻĄDANIA / MIN', 'REQUESTS / MIN')}</span><h2>${T('Ruch', 'Traffic')}</h2></div></div><div id="ch-req"></div></div>
|
||||||
|
<div class="card"><div class="card-heading"><div><span class="small-label">${T('ELEMENTY / MIN', 'ITEMS / MIN')}</span><h2>${T('Synchronizacja danych', 'Data synchronisation')}</h2></div></div><div id="ch-items"></div></div>
|
||||||
|
<div class="card"><div class="card-heading"><div><span class="small-label">${T('TRANSFER / MIN', 'TRANSFER / MIN')}</span><h2>${T('Dane', 'Data')}</h2></div></div><div id="ch-bytes"></div></div>
|
||||||
|
<div class="card"><div class="card-heading"><div><span class="small-label">${T('OSTATNIE 7 DNI', 'LAST 7 DAYS')}</span><h2>${T('Żądania na godzinę', 'Requests per hour')}</h2></div></div><div id="ch-hourly"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid three">
|
||||||
|
<div class="card"><div class="card-heading"><div><span class="small-label">${T('OD STARTU', 'SINCE START')}</span><h2>${T('Komendy', 'Commands')}</h2></div></div><div id="cmds"></div></div>
|
||||||
|
<div class="card"><div class="card-heading"><div><span class="small-label">${T('URZĄDZENIA', 'DEVICES')}</span><h2>${T('Typy urządzeń', 'Device types')}</h2></div></div><div id="types"></div></div>
|
||||||
|
<div class="card"><div class="card-heading"><div><span class="small-label">RUNTIME</span><h2>${T('Serwer', 'Server')}</h2></div></div><dl class="kv" id="runtime"></dl></div>
|
||||||
|
</div>`;
|
||||||
|
const rangeSel = $('#range', v);
|
||||||
|
rangeSel.value = store.get('ap.range', '60');
|
||||||
|
rangeSel.addEventListener('change', () => { store.set('ap.range', rangeSel.value); refresh(); });
|
||||||
|
async function refresh() {
|
||||||
|
const [o, ts] = await Promise.all([api('GET', '/api/overview'), api('GET', `/api/timeseries?minutes=${rangeSel.value}`)]);
|
||||||
|
const lab = ts.map((b) => new Date(b.t * 1000).toLocaleTimeString(LANG === 'pl' ? 'pl-PL' : 'en-GB', { hour: '2-digit', minute: '2-digit' }));
|
||||||
|
const last = ts.slice(-15);
|
||||||
|
const rpm = last.length ? Math.round(last.reduce((a, b) => a + b.requests, 0) / last.length * 10) / 10 : 0;
|
||||||
|
const errRate = o.requests ? ((o.errors / o.requests) * 100).toFixed(2) : '0.00';
|
||||||
|
const tile = (label, value, sub, cls = '', sp = '') => `<div class="card stat ${cls}"><span class="small-label">${esc(label)}</span><div class="value">${value}</div><div class="sub">${sub}</div>${sp}</div>`;
|
||||||
|
$('#tiles').innerHTML = [
|
||||||
|
tile(T('Żądania / min', 'Requests / min'), esc(rpm), T(`łącznie ${fmtNum(o.requests)}`, `total ${fmtNum(o.requests)}`), 'accent', spark(ts.map((b) => b.requests))),
|
||||||
|
tile(T('Połączenia push', 'Push connections'), fmtNum(o.pings), T(`${o.active_requests} aktywnych żądań`, `${o.active_requests} active requests`), 'good', spark(ts.map((b) => b.pings), 'k2')),
|
||||||
|
tile(T('Urządzenia', 'Devices'), `${fmtNum(o.devices_24h)}<small>/ ${fmtNum(o.devices_total)}</small>`, T(`aktywne 24h / wszystkie · ${fmtNum(o.partnerships_total)} partnerstw`, `active 24h / total · ${fmtNum(o.partnerships_total)} partnerships`)),
|
||||||
|
tile(T('Skrzynki', 'Mailboxes'), `${fmtNum(o.users_active_15m)}<small>/ ${fmtNum(o.users_total)}</small>`, T('aktywne 15 min / wszystkie', 'active 15 min / total')),
|
||||||
|
tile(T('Wysłane maile', 'Mails sent'), fmtNum(o.mails_sent), T(`elementy ↓${fmtNum(o.items_out)} ↑${fmtNum(o.items_in)}`, `items ↓${fmtNum(o.items_out)} ↑${fmtNum(o.items_in)}`), '', spark(ts.map((b) => b.mails_sent), 'k3')),
|
||||||
|
tile(T('Błędy', 'Errors'), fmtNum(o.errors), `${errRate}% ${T('żądań', 'of requests')}`, o.errors ? 'bad' : '', spark(ts.map((b) => b.errors), 'k5')),
|
||||||
|
tile(T('Nieudane logowania', 'Failed logins'), fmtNum(o.auth_failures), T(`${o.lockouts} blokad aktywnych`, `${o.lockouts} active lockouts`), o.lockouts ? 'bad' : '', spark(ts.map((b) => b.auth_failures), 'k5')),
|
||||||
|
tile(T('Transfer', 'Transfer'), fmtBytes(o.bytes_out), `↑ ${fmtBytes(o.bytes_in)}`, '', spark(ts.map((b) => b.bytes_out), 'k4')),
|
||||||
|
].join('');
|
||||||
|
if (o.devices_pending || o.wipes_pending) {
|
||||||
|
$('#tiles').insertAdjacentHTML('afterbegin', `<div class="notice">${o.devices_pending ? `<span><b>${o.devices_pending}</b> ${T('urządzeń czeka na zatwierdzenie', 'devices await approval')}</span>` : ''}${o.wipes_pending ? `<span><b>${o.wipes_pending}</b> ${T('zdalnych wymazań w toku', 'remote wipes pending')}</span>` : ''}</div>`);
|
||||||
|
}
|
||||||
|
lineChart($('#ch-req'), { labels: lab, series: [
|
||||||
|
{ name: T('żądania', 'requests'), cls: 'k1', values: ts.map((b) => b.requests) },
|
||||||
|
{ name: 'ping', cls: 'k2', values: ts.map((b) => b.pings) },
|
||||||
|
{ name: T('błędy', 'errors'), cls: 'k5', values: ts.map((b) => b.errors) },
|
||||||
|
] });
|
||||||
|
lineChart($('#ch-items'), { labels: lab, bars: true, series: [
|
||||||
|
{ name: T('do urządzeń', 'to devices'), cls: 'k3', values: ts.map((b) => b.items_out) },
|
||||||
|
{ name: T('z urządzeń', 'from devices'), cls: 'k4', values: ts.map((b) => b.items_in) },
|
||||||
|
] });
|
||||||
|
lineChart($('#ch-bytes'), { labels: lab, fmt: fmtBytes, min: 1024, series: [
|
||||||
|
{ name: T('wysłane', 'sent'), cls: 'k4', values: ts.map((b) => b.bytes_out) },
|
||||||
|
{ name: T('odebrane', 'received'), cls: 'k6', values: ts.map((b) => b.bytes_in) },
|
||||||
|
] });
|
||||||
|
$('#cmds').innerHTML = hbars(o.commands.slice(0, 12).map(([k, s]) => ({ label: k, value: s.count })));
|
||||||
|
applyWidths($('#cmds'));
|
||||||
|
donut($('#types'), o.device_types.map(([t, n]) => ({ label: t, value: n })), o.devices_total);
|
||||||
|
$('#runtime').innerHTML = [
|
||||||
|
[T('Wersja', 'Version'), o.version],
|
||||||
|
['Uptime', fmtDur(o.uptime)],
|
||||||
|
[T('Pamięć RSS', 'Memory RSS'), fmtBytes(o.rss)],
|
||||||
|
[T('Pula IMAP', 'IMAP pool'), o.imap_pool],
|
||||||
|
[T('Baza danych', 'Database'), `${fmtBytes(o.db_bytes)} + WAL ${fmtBytes(o.wal_bytes)}`],
|
||||||
|
['IMAP', o.backends.imap],
|
||||||
|
['SMTP', o.backends.smtp],
|
||||||
|
['CalDAV', o.backends.caldav || T('wyłączony', 'disabled')],
|
||||||
|
['CardDAV', o.backends.carddav || T('wyłączony', 'disabled')],
|
||||||
|
['Provisioning', o.provisioning ? T('włączony', 'enabled') : T('wyłączony', 'disabled')],
|
||||||
|
['Autodiscover', fmtNum(o.autodiscover)],
|
||||||
|
].map(([k, val]) => `<dt>${esc(k)}</dt><dd>${esc(val)}</dd>`).join('');
|
||||||
|
$('#push-count').textContent = `PUSH ${o.pings}`;
|
||||||
|
}
|
||||||
|
async function hourly() {
|
||||||
|
const h = await api('GET', '/api/stats/hourly?hours=168');
|
||||||
|
const lab = h.map((b) => { const d = new Date(b.t * 1000); return `${d.toLocaleDateString(LANG === 'pl' ? 'pl-PL' : 'en-GB', { weekday: 'short' })} ${String(d.getHours()).padStart(2, '0')}:00`; });
|
||||||
|
lineChart($('#ch-hourly'), { labels: lab, bars: true, series: [
|
||||||
|
{ name: T('żądania', 'requests'), cls: 'k1', values: h.map((b) => b.requests) },
|
||||||
|
{ name: T('błędy', 'errors'), cls: 'k5', values: h.map((b) => b.errors) },
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
every(5000, () => refresh().catch(() => {}));
|
||||||
|
every(300000, () => hourly().catch(() => {}));
|
||||||
|
};
|
||||||
|
|
||||||
|
PAGES.live = async (v) => {
|
||||||
|
v.innerHTML = head(T('NA ŻYWO', 'LIVE'), T('Ruch na żywo', 'Live traffic'), T('Trwające żądania, połączenia push (Ping / hanging Sync) i strumień zakończonych żądań.', 'In-flight requests, push connections (Ping / hanging Sync) and a stream of finished requests.'), `<button class="quiet" id="pause">${T('Pauza', 'Pause')}</button>`) +
|
||||||
|
`<div class="grid two">
|
||||||
|
<div class="card"><div class="card-heading"><div><span class="small-label">PUSH</span><h2>${T('Aktywne połączenia push', 'Active push connections')}</h2></div><span class="pill live" id="ping-n">0</span></div><div class="table-scroll" id="pings"></div></div>
|
||||||
|
<div class="card"><div class="card-heading"><div><span class="small-label">IN-FLIGHT</span><h2>${T('Trwające żądania', 'Requests in progress')}</h2></div><span class="pill" id="act-n">0</span></div><div class="table-scroll" id="active"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><div class="card-heading"><div><span class="small-label">STREAM</span><h2>${T('Zakończone żądania', 'Finished requests')}</h2></div>
|
||||||
|
<input id="feed-filter" placeholder="${T('filtr: użytkownik, urządzenie, komenda', 'filter: user, device, command')}"></div>
|
||||||
|
<div class="feed" id="feed"></div></div>`;
|
||||||
|
let paused = false;
|
||||||
|
$('#pause', v).addEventListener('click', (e) => { paused = !paused; e.target.textContent = paused ? T('Wznów', 'Resume') : T('Pauza', 'Pause'); });
|
||||||
|
const filter = $('#feed-filter', v);
|
||||||
|
const feed = $('#feed', v);
|
||||||
|
const row = (e, fresh) => `<div class="feed-row${fresh ? ' fresh' : ''}" data-s="${esc(`${e.user} ${e.device_id} ${e.device_type} ${e.command} ${e.ip}`.toLowerCase())}">
|
||||||
|
<span class="t">${esc(fmtTime(e.ts))}</span><span class="u" title="${esc(e.device_id)}">${esc(e.user)}</span>
|
||||||
|
<span><span class="cmd">${esc(e.command)}</span> ${esc(e.device_type)} <span class="${e.http >= 400 || e.status.startsWith('error') ? 'err' : 'fine'}">${esc(e.http)} ${esc(e.status)}</span>${e.items_out || e.items_in ? ` <span class="fine">↓${e.items_out} ↑${e.items_in}</span>` : ''}</span>
|
||||||
|
<span class="ms">${e.command === 'Ping' ? fmtDur(e.ms / 1000) : `${e.ms} ms`}</span><span class="fine">${fmtBytes(e.bytes_out)}</span></div>`;
|
||||||
|
const applyFilter = () => {
|
||||||
|
const q = filter.value.trim().toLowerCase();
|
||||||
|
$$('.feed-row', feed).forEach((r) => { r.hidden = q && !r.dataset.s.includes(q); });
|
||||||
|
};
|
||||||
|
filter.addEventListener('input', applyFilter);
|
||||||
|
const data = await api('GET', '/api/live');
|
||||||
|
feed.innerHTML = data.recent.map((e) => row(e, false)).join('') || `<div class="empty">${T('Brak żądań', 'No requests yet')}</div>`;
|
||||||
|
S.sse = new EventSource('/api/events');
|
||||||
|
S.sse.addEventListener('request', (m) => {
|
||||||
|
if (paused) return;
|
||||||
|
const e = JSON.parse(m.data);
|
||||||
|
$('.empty', feed)?.remove();
|
||||||
|
feed.insertAdjacentHTML('afterbegin', row(e, true));
|
||||||
|
while (feed.children.length > 400) feed.lastElementChild.remove();
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
|
every(2000, async () => {
|
||||||
|
const d = await api('GET', '/api/live').catch(() => null);
|
||||||
|
if (!d) return;
|
||||||
|
$('#ping-n').textContent = d.pings.length;
|
||||||
|
$('#act-n').textContent = d.active.length;
|
||||||
|
$('#pings').innerHTML = d.pings.length ? `<table><thead><tr><th>${T('Użytkownik', 'User')}</th><th>${T('Urządzenie', 'Device')}</th><th>${T('Typ', 'Kind')}</th><th class="num">${T('Foldery', 'Folders')}</th><th>${T('Heartbeat', 'Heartbeat')}</th></tr></thead><tbody>${d.pings.sort((a, b) => a.started - b.started).map((p) => {
|
||||||
|
const el = d.now - p.started, pct = Math.min(100, (el / Math.max(1, p.lifetime)) * 100);
|
||||||
|
return `<tr><td>${esc(p.user)}</td><td title="${esc(p.device_id)}">${esc(p.device_type)}<span class="sub">${esc(p.device_id)}</span></td><td>${esc(p.kind)}</td><td class="num">${p.folders}</td><td>${fmtDur(el)} / ${fmtDur(p.lifetime)}<div class="countdown"><div data-w="${pct}"></div></div></td></tr>`;
|
||||||
|
}).join('')}</tbody></table>` : `<div class="empty">${T('Brak połączeń push', 'No push connections')}</div>`;
|
||||||
|
$('#active').innerHTML = d.active.length ? `<table><thead><tr><th>${T('Komenda', 'Command')}</th><th>${T('Użytkownik', 'User')}</th><th>${T('Urządzenie', 'Device')}</th><th>IP</th><th class="num">${T('Czas', 'Time')}</th></tr></thead><tbody>${d.active.map((a) => `<tr><td>${esc(a.command)}</td><td>${esc(a.user)}</td><td>${esc(a.device_type)}<span class="sub">AS ${esc(a.version)}</span></td><td>${esc(a.ip)}</td><td class="num">${fmtDur(d.now - a.started)}</td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak', 'None')}</div>`;
|
||||||
|
applyWidths(v);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function deviceState(d) {
|
||||||
|
const p = [];
|
||||||
|
if (d.pinging) p.push(`<span class="pill live">PUSH</span>`);
|
||||||
|
if (d.blocked) p.push(`<span class="pill bad">${T('ZABLOKOWANE', 'BLOCKED')}</span>`);
|
||||||
|
if (!d.approved) p.push(`<span class="pill">${T('DO ZATWIERDZENIA', 'PENDING')}</span>`);
|
||||||
|
if (d.wipe_status === 2 || d.wipe_status === 4) p.push(`<span class="pill bad">WIPE ${d.wipe_status === 4 ? T('WYSŁANY', 'SENT') : T('CZEKA', 'PENDING')}</span>`);
|
||||||
|
if (d.wipe_status === 8) p.push(`<span class="pill bad">${T('WYMAZANE', 'WIPED')}</span>`);
|
||||||
|
if (!p.length) p.push(`<span class="pill ok">OK</span>`);
|
||||||
|
return p.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
PAGES.devices = async (v) => {
|
||||||
|
v.innerHTML = head(T('PARTNERSTWA', 'PARTNERSHIPS'), T('Urządzenia', 'Devices'), T('Jeden wiersz to jedno fizyczne urządzenie; skrzynki skonfigurowane na nim są widoczne jako osobne partnerstwa. Kliknij skrzynkę, aby zobaczyć foldery, historię i akcje (wymazanie, resynchronizacja, blokada).', 'One row per physical device; each mailbox configured on it is a separate partnership. Click a mailbox for folders, history and actions (wipe, resync, block).')) +
|
||||||
|
`<div class="card"><div class="toolbar"><input id="dev-q" placeholder="${T('Szukaj użytkownika, modelu, ID…', 'Search user, model, ID…')}">
|
||||||
|
<select id="dev-f"><option value="">${T('Wszystkie', 'All')}</option><option value="push">PUSH</option><option value="pending">${T('Do zatwierdzenia', 'Pending')}</option><option value="blocked">${T('Zablokowane', 'Blocked')}</option><option value="stale">${T('Nieaktywne >30 dni', 'Inactive >30 days')}</option></select>
|
||||||
|
<span class="fine" id="dev-n"></span></div><div class="table-scroll" id="dev-table"></div></div>`;
|
||||||
|
const q = $('#dev-q', v), f = $('#dev-f', v);
|
||||||
|
q.value = store.get('ap.devq', '');
|
||||||
|
const draw = () => {
|
||||||
|
store.set('ap.devq', q.value);
|
||||||
|
const s = q.value.trim().toLowerCase(), now = Date.now() / 1000;
|
||||||
|
const list = S.devices.filter((d) => {
|
||||||
|
const hay = `${d.username} ${d.device_id} ${d.device_type} ${d.model} ${d.friendly_name} ${d.os} ${d.user_agent} ${d.last_ip}`.toLowerCase();
|
||||||
|
if (s && !hay.includes(s)) return false;
|
||||||
|
if (f.value === 'push') return d.pinging;
|
||||||
|
if (f.value === 'pending') return !d.approved;
|
||||||
|
if (f.value === 'blocked') return d.blocked;
|
||||||
|
if (f.value === 'stale') return now - d.last_seen > 30 * 86400;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
// one row per physical device (DeviceId); every mailbox on it is a partnership chip
|
||||||
|
const groups = new Map();
|
||||||
|
list.forEach((d) => {
|
||||||
|
if (!groups.has(d.device_id)) groups.set(d.device_id, []);
|
||||||
|
groups.get(d.device_id).push(d);
|
||||||
|
});
|
||||||
|
const rows = Array.from(groups.values()).map((g) => g.sort((a, b2) => b2.last_seen - a.last_seen))
|
||||||
|
.sort((a, b2) => b2[0].last_seen - a[0].last_seen);
|
||||||
|
const physical = new Set(S.devices.map((d) => d.device_id)).size;
|
||||||
|
$('#dev-n').textContent = T(`${rows.length} / ${physical} urządzeń · ${list.length} partnerstw`, `${rows.length} / ${physical} devices · ${list.length} partnerships`);
|
||||||
|
$('#dev-table').innerHTML = rows.length ? `<table><thead><tr><th>${T('Urządzenie', 'Device')}</th><th>${T('Skrzynki', 'Mailboxes')}</th><th>OS</th><th>AS</th><th>${T('Ostatnio', 'Last seen')}</th><th>IP</th><th class="num">${T('Żądania', 'Requests')}</th><th>${T('Stan', 'State')}</th></tr></thead><tbody>${rows.map((g) => {
|
||||||
|
const d = g[0];
|
||||||
|
const merged = { ...d, pinging: g.some((x) => x.pinging), blocked: g.some((x) => x.blocked), approved: g.every((x) => x.approved), wipe_status: Math.max(...g.map((x) => x.wipe_status)) };
|
||||||
|
return `<tr class="clickable" data-id="${d.id}">
|
||||||
|
<td>${esc(d.friendly_name || d.model || d.device_type)}<span class="sub">${esc(d.device_type)} · ${esc(d.device_id)}</span></td>
|
||||||
|
<td class="wrap">${g.map((x) => `<button class="chip" data-pid="${x.id}" title="${esc(ago(x.last_seen))}">${x.pinging ? '<span class="signal"></span>' : ''}${esc(x.username)}</button>`).join('')}</td>
|
||||||
|
<td>${esc(d.os || '-')}</td><td>${esc(d.as_version || '-')}</td><td title="${esc(fmtTs(d.last_seen))}">${esc(ago(d.last_seen))}<span class="sub">${esc(d.last_command)}</span></td>
|
||||||
|
<td>${esc(d.last_ip)}</td><td class="num">${fmtNum(g.reduce((a, x) => a + x.requests, 0))}</td><td>${deviceState(merged)}</td></tr>`;
|
||||||
|
}).join('')}</tbody></table>` : `<div class="empty">${T('Brak urządzeń', 'No devices')}</div>`;
|
||||||
|
$$('.chip[data-pid]', v).forEach((c) => c.addEventListener('click', (e) => { e.stopPropagation(); deviceDialog(Number(c.dataset.pid), load); }));
|
||||||
|
$$('tr.clickable', v).forEach((tr) => tr.addEventListener('click', () => deviceDialog(Number(tr.dataset.id), load)));
|
||||||
|
};
|
||||||
|
async function load() {
|
||||||
|
S.devices = await api('GET', '/api/devices');
|
||||||
|
const nc = $('#nav-dev-count');
|
||||||
|
if (nc) nc.textContent = new Set(S.devices.map((d) => d.device_id)).size;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
q.addEventListener('input', draw);
|
||||||
|
f.addEventListener('change', draw);
|
||||||
|
every(10000, () => load().catch(() => {}));
|
||||||
|
};
|
||||||
|
|
||||||
|
async function deviceDialog(id, reload) {
|
||||||
|
const d = await api('GET', `/api/devices/${id}`);
|
||||||
|
const kv = [
|
||||||
|
[T('Użytkownik', 'User'), d.username], ['Device ID', d.device_id], [T('Typ', 'Type'), d.device_type], [T('Model', 'Model'), d.model],
|
||||||
|
[T('Nazwa', 'Friendly name'), d.friendly_name], ['OS', `${d.os} ${d.os_language}`], ['IMEI', d.imei], [T('Telefon', 'Phone'), d.phone_number],
|
||||||
|
[T('Operator', 'Operator'), d.mobile_operator], ['User-Agent', d.user_agent], ['ActiveSync', d.as_version],
|
||||||
|
[T('Polityka', 'Policy'), `${d.policy_name || '-'} (key ${d.policy_key || 0})`], [T('Pierwszy raz', 'First seen'), fmtTs(d.first_seen)],
|
||||||
|
[T('Ostatnio', 'Last seen'), `${fmtTs(d.last_seen)} · ${d.last_command} · ${d.last_ip}`], [T('Żądania', 'Requests'), fmtNum(d.requests)],
|
||||||
|
[T('Ping', 'Ping'), d.ping_lifetime ? `${d.ping_lifetime}s` : '-'],
|
||||||
|
['Wipe', d.wipe_requested_at ? `${T('zlecony', 'requested')} ${fmtTs(d.wipe_requested_at)} ${T('przez', 'by')} ${d.wipe_requested_by || ''}${d.wipe_completed_at ? ` · ${T('potwierdzony', 'acknowledged')} ${fmtTs(d.wipe_completed_at)}` : ''}` : '-'],
|
||||||
|
].filter(([, val]) => val && String(val).trim());
|
||||||
|
const folders = d.folders.length ? `<table><thead><tr><th>${T('Folder', 'Folder')}</th><th>${T('Klasa', 'Class')}</th><th class="num">${T('Elementy', 'Items')}</th><th class="num">↓</th><th class="num">↑</th><th>${T('Ostatnia sync.', 'Last sync')}</th><th class="num">${T('Okno', 'Window')}</th><th class="num">${T('Pętle', 'Loops')}</th></tr></thead><tbody>${d.folders.map((f) => `<tr><td>${esc(f.folder_name)}<span class="sub">id ${esc(f.collection)}</span></td><td>${esc(f.folder_class)}</td><td class="num">${fmtNum(f.item_count)}</td><td class="num">${fmtNum(f.items_out)}</td><td class="num">${fmtNum(f.items_in)}</td><td>${esc(ago(f.last_sync))}</td><td class="num">${f.window_size}</td><td class="num">${f.loop_hits ? `<span class="pill bad">${f.loop_hits}</span>` : 0}</td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak zsynchronizowanych folderów', 'No synchronised folders')}</div>`;
|
||||||
|
const recent = d.recent.length ? `<div class="feed">${d.recent.map((e) => `<div class="feed-row"><span class="t">${esc(fmtTime(e.ts))}</span><span class="u">${esc(e.ip)}</span><span><span class="cmd">${esc(e.command)}</span> <span class="${e.http >= 400 ? 'err' : 'fine'}">${e.http} ${esc(e.status)}</span></span><span class="ms">${e.ms} ms</span><span class="fine">${fmtBytes(e.bytes_out)}</span></div>`).join('')}</div>` : `<div class="empty">-</div>`;
|
||||||
|
const btn = (act, label, cls = 'outline') => `<button class="${cls}" data-act="${act}">${esc(label)}</button>`;
|
||||||
|
const dlg = modal(`<div class="dialog-head"><div><span class="small-label">${T('URZĄDZENIE', 'DEVICE')} #${d.id}</span><h2>${esc(d.friendly_name || d.model || d.device_type)} - ${esc(d.username)}</h2><div>${deviceState(d)}</div></div><button class="quiet" data-close>✕</button></div>
|
||||||
|
<div class="actions-row">
|
||||||
|
${d.approved ? '' : btn('approve', T('Zatwierdź', 'Approve'), 'primary')}
|
||||||
|
${btn('resync', T('Resynchronizuj wszystko', 'Full resync'))}
|
||||||
|
${btn('reprovision', T('Wymuś provisioning', 'Force re-provision'))}
|
||||||
|
${d.blocked ? btn('unblock', T('Odblokuj', 'Unblock')) : btn('block', T('Zablokuj', 'Block'), 'danger')}
|
||||||
|
${d.wipe_status === 2 || d.wipe_status === 4 ? btn('cancel-wipe', T('Anuluj wymazanie', 'Cancel wipe')) : btn('wipe', T('Zdalnie wymaż urządzenie', 'Remote wipe device'), 'danger')}
|
||||||
|
${d.has_recovery_password ? btn('recovery-password', T('Hasło odzyskiwania', 'Recovery password'), 'quiet') : ''}
|
||||||
|
${btn('remove', T('Usuń partnerstwo', 'Remove partnership'), 'danger')}
|
||||||
|
</div>
|
||||||
|
<div class="grid two"><div><h3>${T('Szczegóły', 'Details')}</h3><dl class="kv">${kv.map(([k, val]) => `<dt>${esc(k)}</dt><dd>${esc(val)}</dd>`).join('')}</dl>
|
||||||
|
<h3 class="mt">${T('Notatki', 'Notes')}</h3><textarea id="dev-notes" maxlength="2000">${esc(d.notes)}</textarea><div class="form-actions"><button class="quiet" data-act="notes">${T('Zapisz notatkę', 'Save note')}</button></div></div>
|
||||||
|
<div><h3>${T('Ostatnie żądania', 'Recent requests')}</h3>${recent}</div></div>
|
||||||
|
<h3>${T('Foldery', 'Folders')}</h3><div class="table-scroll">${folders}</div>`);
|
||||||
|
$$('[data-act]', dlg).forEach((b) => b.addEventListener('click', async () => {
|
||||||
|
const act = b.dataset.act;
|
||||||
|
if (act === 'wipe' && !(await confirmBox(T('Zdalne wymazanie', 'Remote wipe'), T('Urządzenie przy następnym połączeniu wykona reset do ustawień fabrycznych i usunie WSZYSTKIE dane. Tej operacji nie można cofnąć po jej wykonaniu przez urządzenie.', 'On its next connection the device performs a factory reset and erases ALL data. This cannot be undone once the device executes it.'), true, d.device_id))) return;
|
||||||
|
if (act === 'remove' && !(await confirmBox(T('Usunąć partnerstwo?', 'Remove partnership?'), T('Stany synchronizacji zostaną usunięte; urządzenie zsynchronizuje się od nowa przy następnym połączeniu.', 'Sync states are removed; the device will resync from scratch on its next connection.'), true))) return;
|
||||||
|
if (act === 'block' && !(await confirmBox(T('Zablokować urządzenie?', 'Block device?'), T('Urządzenie otrzyma HTTP 403 przy każdej próbie synchronizacji.', 'The device receives HTTP 403 on every sync attempt.'), true))) return;
|
||||||
|
try {
|
||||||
|
if (act === 'recovery-password') {
|
||||||
|
const r = await api('POST', `/api/devices/${id}/recovery-password`);
|
||||||
|
toast(`${T('Hasło odzyskiwania', 'Recovery password')}: ${r.password}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = act === 'notes' ? { notes: $('#dev-notes', dlg).value } : undefined;
|
||||||
|
await api('POST', `/api/devices/${id}/${act}`, body);
|
||||||
|
toast(T('Zapisano', 'Done'));
|
||||||
|
if (act === 'remove') { closeModal(); } else if (act !== 'notes') { deviceDialog(id, reload); }
|
||||||
|
reload && reload();
|
||||||
|
} catch (e) { toast(e.message, true); }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
PAGES.users = async (v) => {
|
||||||
|
v.innerHTML = head(T('KONTA', 'ACCOUNTS'), T('Skrzynki', 'Mailboxes'), T('Skrzynki (loginy) synchronizowane przez ActiveSync. Jedna osoba może mieć kilka skrzynek na tym samym telefonie - kolumna "Na tym samym urządzeniu" je łączy. Blokada działa natychmiast na wszystkich urządzeniach.', 'Mailboxes (logins) syncing through ActiveSync. One person may have several mailboxes on the same phone - the "Same device as" column links them. Blocking applies immediately on every device.')) +
|
||||||
|
`<div class="card"><div class="toolbar"><input id="u-q" placeholder="${T('Szukaj', 'Search')}"></div><div class="table-scroll" id="u-table"></div></div>
|
||||||
|
<div class="card" id="lock-card"><div class="card-heading"><div><span class="small-label">${T('OCHRONA', 'PROTECTION')}</span><h2>${T('Aktywne blokady logowania', 'Active login lockouts')}</h2></div></div><div id="locks"></div></div>`;
|
||||||
|
let users = [];
|
||||||
|
const draw = () => {
|
||||||
|
const s = $('#u-q', v).value.trim().toLowerCase();
|
||||||
|
const list = users.filter((u) => !s || u.username.includes(s));
|
||||||
|
$('#u-table').innerHTML = list.length ? `<table><thead><tr><th>${T('Skrzynka', 'Mailbox')}</th><th>${T('Urządzenia', 'Devices')}</th><th>${T('Na tym samym urządzeniu', 'Same device as')}</th><th class="num">PUSH</th><th class="num">${T('Żądania', 'Requests')}</th><th>${T('Ostatnio', 'Last seen')}</th><th>${T('Polityka', 'Policy')}</th><th>${T('Stan', 'State')}</th><th></th></tr></thead><tbody>${list.map((u) => `<tr><td>${esc(u.username)}</td>
|
||||||
|
<td class="wrap">${esc((u.device_names || '-').split(',').join(', '))}<span class="sub">${u.devices} ${T('partnerstw', 'partnership(s)')}</span></td>
|
||||||
|
<td class="wrap">${u.same_device ? u.same_device.split(',').map((x) => `<span class="chip">${esc(x)}</span>`).join('') : '<span class="fine">-</span>'}</td>
|
||||||
|
<td class="num">${u.pings}</td><td class="num">${fmtNum(u.requests)}</td><td>${esc(ago(u.last_seen))}</td><td>${esc(u.policy)}</td><td>${u.blocked ? `<span class="pill bad">${T('ZABLOKOWANA', 'BLOCKED')}</span>` : '<span class="pill ok">OK</span>'}</td>
|
||||||
|
<td><button class="${u.blocked ? 'outline' : 'danger'}" data-u="${esc(u.username)}" data-b="${u.blocked ? 0 : 1}">${u.blocked ? T('Odblokuj', 'Unblock') : T('Zablokuj', 'Block')}</button> <button class="quiet" data-dev="${esc(u.username)}">${T('Urządzenia', 'Devices')}</button></td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak skrzynek', 'No mailboxes')}</div>`;
|
||||||
|
$$('[data-u]', v).forEach((b) => b.addEventListener('click', async () => {
|
||||||
|
const name = b.dataset.u, block = b.dataset.b === '1';
|
||||||
|
if (block && !(await confirmBox(T('Zablokować skrzynkę?', 'Block mailbox?'), name, true))) return;
|
||||||
|
const s2 = await loadSettings();
|
||||||
|
const set = new Set(s2.security.blocked_users.map((x) => x.toLowerCase()));
|
||||||
|
if (block) set.add(name.toLowerCase()); else set.delete(name.toLowerCase());
|
||||||
|
s2.security.blocked_users = Array.from(set);
|
||||||
|
await saveSettings(s2);
|
||||||
|
load();
|
||||||
|
}));
|
||||||
|
$$('[data-dev]', v).forEach((b) => b.addEventListener('click', () => { store.set('ap.devq', b.dataset.dev); location.hash = 'devices'; }));
|
||||||
|
};
|
||||||
|
async function load() {
|
||||||
|
users = await api('GET', '/api/users');
|
||||||
|
draw();
|
||||||
|
const locks = await api('GET', '/api/lockouts');
|
||||||
|
$('#locks').innerHTML = locks.length ? `<table><thead><tr><th>${T('Klucz', 'Key')}</th><th>${T('Do', 'Until')}</th><th></th></tr></thead><tbody>${locks.map((l) => `<tr><td>${esc(l.key)}</td><td>${esc(fmtTs(l.until))}</td><td><button class="outline" data-unlock="${esc(l.key)}">${T('Odblokuj', 'Unlock')}</button></td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak blokad', 'No lockouts')}</div>`;
|
||||||
|
$$('[data-unlock]', v).forEach((b) => b.addEventListener('click', async () => { await api('POST', '/api/lockouts/unlock', { key: b.dataset.unlock }); load(); }));
|
||||||
|
}
|
||||||
|
$('#u-q', v).addEventListener('input', draw);
|
||||||
|
every(15000, () => load().catch(() => {}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ settings forms
|
||||||
|
async function loadSettings() {
|
||||||
|
const r = await api('GET', '/api/settings');
|
||||||
|
S.settings = r.settings;
|
||||||
|
S.timezones = r.timezones;
|
||||||
|
return JSON.parse(JSON.stringify(r.settings));
|
||||||
|
}
|
||||||
|
async function saveSettings(s) {
|
||||||
|
const r = await api('PUT', '/api/settings', s);
|
||||||
|
toast(T(`Zapisano i zastosowano (rewizja ${r.revision})`, `Saved and applied (revision ${r.revision})`));
|
||||||
|
return loadSettings();
|
||||||
|
}
|
||||||
|
const getP = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);
|
||||||
|
function setP(o, p, val) {
|
||||||
|
const ks = p.split('.');
|
||||||
|
let c = o;
|
||||||
|
ks.slice(0, -1).forEach((k) => { c = c[k]; });
|
||||||
|
c[ks[ks.length - 1]] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** field: [path, label, type, opts] type: text|number|bool|select|list|secret|tz|textarea */
|
||||||
|
function fieldHtml(s, f) {
|
||||||
|
const [path, label, type, opt = {}] = f;
|
||||||
|
const val = getP(s, path);
|
||||||
|
const id = `f-${path.replace(/\./g, '-')}`;
|
||||||
|
const help = opt.help ? `<div class="help">${esc(opt.help)}</div>` : '';
|
||||||
|
const wide = opt.wide || type === 'list' || type === 'textarea' ? ' wide' : '';
|
||||||
|
if (type === 'bool') return `<div class="field${wide}"><label class="check"><input type="checkbox" id="${id}" data-path="${path}" data-type="bool" ${val ? 'checked' : ''}> ${esc(label)}</label>${help}</div>`;
|
||||||
|
let input;
|
||||||
|
if (type === 'select') input = `<select id="${id}" data-path="${path}" data-type="${opt.num ? 'number' : 'text'}">${opt.options.map(([k, l]) => `<option value="${esc(k)}" ${String(val) === String(k) ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`;
|
||||||
|
else if (type === 'tz') input = `<input id="${id}" data-path="${path}" data-type="text" list="tz-list" value="${esc(val)}"><datalist id="tz-list">${S.timezones.map((t) => `<option value="${esc(t)}">`).join('')}</datalist>`;
|
||||||
|
else if (type === 'list') input = `<textarea id="${id}" data-path="${path}" data-type="list" spellcheck="false" placeholder="${esc(opt.ph || T('jedna wartość w linii', 'one value per line'))}">${esc((val || []).join('\n'))}</textarea>`;
|
||||||
|
else if (type === 'secret') input = `<input id="${id}" type="password" data-path="${path}" data-type="secret" autocomplete="new-password" placeholder="${val === '__SET__' ? T('•••••• (ustawione - zostaw puste, by nie zmieniać)', '•••••• (set - leave empty to keep)') : ''}">`;
|
||||||
|
else if (type === 'number') input = `<input id="${id}" type="number" data-path="${path}" data-type="number" value="${esc(val)}" ${opt.min != null ? `min="${opt.min}"` : ''} ${opt.max != null ? `max="${opt.max}"` : ''}>`;
|
||||||
|
else input = `<input id="${id}" data-path="${path}" data-type="text" value="${esc(val)}" spellcheck="false" ${opt.ph ? `placeholder="${esc(opt.ph)}"` : ''}>`;
|
||||||
|
return `<div class="field${wide}"><label for="${id}">${esc(label)}</label>${input}${help}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(root, s) {
|
||||||
|
$$('[data-path]', root).forEach((el) => {
|
||||||
|
const p = el.dataset.path, t = el.dataset.type;
|
||||||
|
let val;
|
||||||
|
if (t === 'bool') val = el.checked;
|
||||||
|
else if (t === 'number') val = el.value === '' ? 0 : Number(el.value);
|
||||||
|
else if (t === 'list') val = el.value.split('\n').map((x) => x.trim()).filter(Boolean);
|
||||||
|
else if (t === 'secret') val = el.value === '' ? (getP(S.settings, p) === '__SET__' ? '__SET__' : '') : el.value;
|
||||||
|
else val = el.value.trim();
|
||||||
|
setP(s, p, val);
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(s, title, desc, fields, extra = '') {
|
||||||
|
return `<div class="card"><div class="section-title"><div><h2>${esc(title)}</h2>${desc ? `<p class="fine">${esc(desc)}</p>` : ''}</div></div><div class="form-grid">${fields.map((f) => fieldHtml(s, f)).join('')}</div>${extra}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settingsPage(v, eyebrow, title, desc, build, after) {
|
||||||
|
const s = await loadSettings();
|
||||||
|
v.innerHTML = head(eyebrow, title, desc, `<span class="dirty-flag" id="dirty" hidden>● ${T('niezapisane zmiany', 'unsaved changes')}</span><button class="quiet" id="reset">${T('Cofnij', 'Revert')}</button><button class="primary" id="save">${T('Zapisz i zastosuj', 'Save & apply')}</button>`) + `<div class="stack" id="form">${build(s)}</div>`;
|
||||||
|
const form = $('#form', v);
|
||||||
|
form.addEventListener('input', () => { $('#dirty').hidden = false; });
|
||||||
|
form.addEventListener('change', () => { $('#dirty').hidden = false; });
|
||||||
|
$('#reset', v).addEventListener('click', () => go());
|
||||||
|
$('#save', v).addEventListener('click', async (e) => {
|
||||||
|
e.target.disabled = true;
|
||||||
|
try {
|
||||||
|
const cur = JSON.parse(JSON.stringify(S.settings));
|
||||||
|
collect(form, cur);
|
||||||
|
if (after?.collect) after.collect(form, cur);
|
||||||
|
await saveSettings(cur);
|
||||||
|
go();
|
||||||
|
} catch (err) { toast(err.message, true); } finally { e.target.disabled = false; }
|
||||||
|
});
|
||||||
|
after?.bind?.(form, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testBlock(kind) {
|
||||||
|
return `<div class="test-row"><div><label>${T('Login testowy', 'Test login')}</label><input data-test-user="${kind}" autocomplete="off" placeholder="user@example.com"></div>
|
||||||
|
<div><label>${T('Hasło testowe (nie jest zapisywane)', 'Test password (not stored)')}</label><input type="password" data-test-pass="${kind}" autocomplete="new-password"></div>
|
||||||
|
<button class="outline" data-test="${kind}">${T('Testuj połączenie', 'Test connection')}</button></div><div class="result" data-result="${kind}" hidden></div>`;
|
||||||
|
}
|
||||||
|
function bindTests(form) {
|
||||||
|
$$('[data-test]', form).forEach((b) => b.addEventListener('click', async () => {
|
||||||
|
const k = b.dataset.test;
|
||||||
|
const out = $(`[data-result="${k}"]`, form);
|
||||||
|
out.hidden = false; out.className = 'result'; out.textContent = T('Testowanie…', 'Testing…');
|
||||||
|
b.disabled = true;
|
||||||
|
try {
|
||||||
|
const cur = collect(form, JSON.parse(JSON.stringify(S.settings)));
|
||||||
|
const r = await api('POST', `/api/test/${k}`, { login: $(`[data-test-user="${k}"]`, form).value, password: $(`[data-test-pass="${k}"]`, form).value, settings: cur });
|
||||||
|
out.classList.add(r.ok ? 'ok' : 'bad');
|
||||||
|
out.textContent = r.message;
|
||||||
|
} catch (e) { out.classList.add('bad'); out.textContent = e.message; } finally { b.disabled = false; }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const TLS = () => [['none', T('brak (tylko localhost)', 'none (localhost only)')], ['start_tls', 'STARTTLS'], ['tls', T('TLS (implicit)', 'TLS (implicit)')]];
|
||||||
|
|
||||||
|
PAGES.backends = (v) => settingsPage(v, T('ŹRÓDŁA DANYCH', 'DATA SOURCES'), T('Backendy', 'Backends'), T('Poczta przez IMAP/SMTP, kalendarze i zadania przez CalDAV, kontakty przez CardDAV - odpowiednik backendu "combined" z Z-Push.', 'Mail via IMAP/SMTP, calendars and tasks via CalDAV, contacts via CardDAV - the Z-Push "combined" backend.'), (s) =>
|
||||||
|
section(s, 'IMAP', T('Serwer poczty przychodzącej (np. Dovecot). Uwierzytelnianie urządzeń odbywa się loginem IMAP.', 'Incoming mail server (e.g. Dovecot). Devices authenticate with their IMAP login.'), [
|
||||||
|
['imap.host', T('Host', 'Host'), 'text'], ['imap.port', T('Port', 'Port'), 'number', { min: 1, max: 65535 }],
|
||||||
|
['imap.security', T('Szyfrowanie', 'Encryption'), 'select', { options: TLS() }], ['imap.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
|
||||||
|
['imap.folder_prefix', T('Prefiks folderów', 'Folder prefix'), 'text', { ph: 'INBOX.' }],
|
||||||
|
['imap.use_special_use', T('Rozpoznawaj foldery po SPECIAL-USE', 'Detect folders via SPECIAL-USE'), 'bool'],
|
||||||
|
['imap.folder_inbox', 'Inbox', 'text'], ['imap.folder_sent', T('Wysłane', 'Sent'), 'text'], ['imap.folder_drafts', T('Szkice', 'Drafts'), 'text'],
|
||||||
|
['imap.folder_trash', T('Kosz', 'Trash'), 'text'], ['imap.folder_spam', 'Spam', 'text'], ['imap.folder_archive', T('Archiwum', 'Archive'), 'text'],
|
||||||
|
['imap.excluded_folders', T('Ukryte foldery', 'Hidden folders'), 'list', { ph: 'Shared/*\nPublic' }],
|
||||||
|
['imap.autoseen_on_delete', T('Oznacz jako przeczytane przed usunięciem', 'Mark read before delete'), 'bool'],
|
||||||
|
['imap.inline_forward', T('Przekazywanie inline (zamiast załącznika .eml)', 'Inline forward (instead of .eml)'), 'bool'],
|
||||||
|
['imap.timeout_secs', T('Timeout (s)', 'Timeout (s)'), 'number', { min: 5 }], ['imap.pool_idle_secs', T('Czas życia połączeń w puli (s)', 'Pool idle time (s)'), 'number'],
|
||||||
|
['imap.pool_max_per_user', T('Maks. połączeń w puli / użytkownika', 'Max pooled per user'), 'number', { min: 0, max: 20 }],
|
||||||
|
], testBlock('imap')) +
|
||||||
|
section(s, 'SMTP', T('Wysyłka poczty z urządzeń (SendMail / SmartReply / SmartForward).', 'Outgoing mail from devices (SendMail / SmartReply / SmartForward).'), [
|
||||||
|
['smtp.method', T('Metoda', 'Method'), 'select', { options: [['smtp', 'SMTP'], ['sendmail', 'sendmail']] }],
|
||||||
|
['smtp.host', 'Host', 'text'], ['smtp.port', 'Port', 'number'], ['smtp.security', T('Szyfrowanie', 'Encryption'), 'select', { options: TLS() }],
|
||||||
|
['smtp.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
|
||||||
|
['smtp.auth', T('Uwierzytelnianie', 'Authentication'), 'select', { options: [['user_credentials', T('dane użytkownika urządzenia', 'device user credentials')], ['static', T('stałe konto relay', 'static relay account')], ['none', T('brak', 'none')]] }],
|
||||||
|
['smtp.username', T('Login relay', 'Relay username'), 'text'], ['smtp.password', T('Hasło relay', 'Relay password'), 'secret'],
|
||||||
|
['smtp.sendmail_path', T('Ścieżka sendmail', 'sendmail path'), 'text'], ['smtp.timeout_secs', 'Timeout (s)', 'number'],
|
||||||
|
['smtp.max_message_mb', T('Maks. rozmiar wiadomości (MB)', 'Max message size (MB)'), 'number'],
|
||||||
|
['smtp.save_in_sent', T('Zapisuj kopię w Wysłanych', 'Save copy to Sent'), 'bool'],
|
||||||
|
['smtp.enforce_from', T('Wymuszaj adres nadawcy = adres konta', 'Force sender = account address'), 'bool', { help: T('Zapobiega podszywaniu się pod inne adresy.', 'Prevents sender spoofing.') }],
|
||||||
|
], testBlock('smtp')) +
|
||||||
|
section(s, 'CalDAV', T('Kalendarze i zadania (VEVENT/VTODO). Placeholders: %u login, %l część lokalna, %d domena.', 'Calendars and tasks (VEVENT/VTODO). Placeholders: %u login, %l local part, %d domain.'), [
|
||||||
|
['caldav.enabled', T('Włączony', 'Enabled'), 'bool'], ['caldav.base_url', 'URL', 'text', { ph: 'https://dav.example.com' }],
|
||||||
|
['caldav.path', T('Ścieżka kalendarzy', 'Calendar home path'), 'text', { ph: '/dav/calendars/%u/' }], ['caldav.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
|
||||||
|
['caldav.default_collection', T('Domyślny kalendarz', 'Default calendar'), 'text'], ['caldav.include_regex', T('Tylko kolekcje pasujące do regex', 'Include regex'), 'text'],
|
||||||
|
['caldav.exclude_regex', T('Pomiń kolekcje pasujące do regex', 'Exclude regex'), 'text'], ['caldav.timeout_secs', 'Timeout (s)', 'number'],
|
||||||
|
], testBlock('caldav')) +
|
||||||
|
section(s, 'CardDAV', T('Kontakty oraz globalna książka adresowa (GAL) do wyszukiwania i rozwiązywania odbiorców.', 'Contacts plus a global address list (GAL) for search and recipient resolution.'), [
|
||||||
|
['carddav.enabled', T('Włączony', 'Enabled'), 'bool'], ['carddav.base_url', 'URL', 'text'],
|
||||||
|
['carddav.path', T('Ścieżka książek adresowych', 'Address book home path'), 'text', { ph: '/dav/addressbooks/%u/' }], ['carddav.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
|
||||||
|
['carddav.default_collection', T('Domyślna książka', 'Default book'), 'text'], ['carddav.gal_path', T('Ścieżka GAL', 'GAL path'), 'text', { ph: '/dav/addressbooks/%d/gal/' }],
|
||||||
|
['carddav.gal_min_length', T('Min. długość zapytania GAL', 'GAL min query length'), 'number'], ['carddav.max_picture_kb', T('Maks. zdjęcie kontaktu (KB)', 'Max contact picture (KB)'), 'number'],
|
||||||
|
['carddav.include_regex', 'Include regex', 'text'], ['carddav.exclude_regex', 'Exclude regex', 'text'], ['carddav.timeout_secs', 'Timeout (s)', 'number'],
|
||||||
|
], testBlock('carddav')), { bind: bindTests });
|
||||||
|
|
||||||
|
PAGES.activesync = (v) => settingsPage(v, 'PROTOCOL', 'ActiveSync', T('Logowanie, protokół, synchronizacja, push i Autodiscover.', 'Login, protocol, synchronisation, push and Autodiscover.'), (s) =>
|
||||||
|
section(s, T('Ogólne', 'General'), '', [
|
||||||
|
['general.server_name', T('Nazwa serwera', 'Server name'), 'text'], ['general.default_timezone', T('Domyślna strefa czasowa', 'Default time zone'), 'tz'],
|
||||||
|
['general.login_format', T('Format loginu', 'Login format'), 'select', { options: [['as_given', T('jak podany przez urządzenie', 'as given by device')], ['local_part', T('tylko część lokalna (bez @domena)', 'local part only')], ['full_email', T('pełny e-mail (dopisz domenę)', 'full e-mail (append domain)')]], help: T('Odpowiednik USE_FULLEMAIL_FOR_LOGIN. Prefiks DOMENA\\ jest zawsze usuwany.', 'Equivalent of USE_FULLEMAIL_FOR_LOGIN. DOMAIN\\ prefixes are always stripped.') }],
|
||||||
|
['general.default_domain', T('Domyślna domena', 'Default domain'), 'text'],
|
||||||
|
['general.email_template', T('Szablon adresu e-mail', 'E-mail address template'), 'text', { help: '{login} {local} {domain}' }],
|
||||||
|
['general.max_protocol', T('Najwyższa wersja protokołu', 'Highest protocol version'), 'select', { options: ['14.1', '14.0', '12.1', '12.0', '2.5'].map((x) => [x, x]) }],
|
||||||
|
['general.allowed_domains', T('Dozwolone domeny (puste = wszystkie)', 'Allowed domains (empty = all)'), 'list'],
|
||||||
|
]) +
|
||||||
|
section(s, T('Synchronizacja', 'Synchronisation'), '', [
|
||||||
|
['sync.max_items', T('Maks. elementów w paczce (SYNC_MAX_ITEMS)', 'Max items per batch'), 'number', { min: 1, max: 2000 }],
|
||||||
|
['sync.filter_time_max', T('Maks. zakres czasu', 'Max time window'), 'select', { num: true, options: [[0, T('bez limitu', 'unlimited')], [1, T('1 dzień', '1 day')], [2, T('3 dni', '3 days')], [3, T('1 tydzień', '1 week')], [4, T('2 tygodnie', '2 weeks')], [5, T('1 miesiąc', '1 month')], [6, T('3 miesiące', '3 months')], [7, T('6 miesięcy', '6 months')]] }],
|
||||||
|
['sync.server_wins', T('Konflikty: wygrywa serwer', 'Conflicts: server wins'), 'bool'], ['sync.loop_detection', T('Wykrywanie pętli synchronizacji', 'Sync loop detection'), 'bool'],
|
||||||
|
['sync.max_attachment_mb', T('Maks. załącznik (MB)', 'Max attachment (MB)'), 'number'], ['sync.max_request_mb', T('Maks. żądanie (MB)', 'Max request (MB)'), 'number'],
|
||||||
|
['sync.heartbeat_min', T('Hanging Sync min (s)', 'Hanging Sync min (s)'), 'number'], ['sync.heartbeat_max', T('Hanging Sync max (s)', 'Hanging Sync max (s)'), 'number'],
|
||||||
|
['sync.search_wait_secs', T('Limit czasu wyszukiwania (s)', 'Search time limit (s)'), 'number'], ['sync.search_max_results', T('Maks. wyników wyszukiwania', 'Max search results'), 'number'],
|
||||||
|
['sync.retry_after_secs', 'Retry-After (s)', 'number'],
|
||||||
|
]) +
|
||||||
|
section(s, 'Push (Ping)', T('Urządzenia trzymają otwarte połączenie; serwer sprawdza zmiany w backendzie co zadany interwał.', 'Devices keep a connection open; the server checks the backend for changes every interval.'), [
|
||||||
|
['ping.check_interval_secs', T('Interwał sprawdzania (s)', 'Check interval (s)'), 'number', { min: 5 }],
|
||||||
|
['ping.lifetime_min', T('Min. heartbeat (s)', 'Min heartbeat (s)'), 'number'], ['ping.lifetime_max', T('Maks. heartbeat (s)', 'Max heartbeat (s)'), 'number'],
|
||||||
|
['ping.max_folders', T('Maks. folderów', 'Max folders'), 'number'],
|
||||||
|
]) +
|
||||||
|
section(s, 'Autodiscover', T('Automatyczna konfiguracja kont na telefonach (/autodiscover/autodiscover.xml).', 'Automatic account configuration (/autodiscover/autodiscover.xml).'), [
|
||||||
|
['autodiscover.enabled', T('Włączony', 'Enabled'), 'bool'], ['autodiscover.require_auth', T('Wymagaj poprawnego hasła', 'Require valid password'), 'bool'],
|
||||||
|
['autodiscover.server_url', T('Publiczny URL ActiveSync', 'Public ActiveSync URL'), 'text', { wide: true, ph: 'https://mail.example.com/Microsoft-Server-ActiveSync', help: T('Puste = wyznaczony z nagłówka Host.', 'Empty = derived from the Host header.') }],
|
||||||
|
]) +
|
||||||
|
section(s, T('Logowanie i retencja', 'Logging & retention'), '', [
|
||||||
|
['logging.log_wbxml', T('Loguj zdekodowany WBXML (debug, zawiera treść poczty!)', 'Log decoded WBXML (debug, contains mail data!)'), 'bool'],
|
||||||
|
['logging.debug_users', T('Użytkownicy z logowaniem debug', 'Debug users'), 'list'],
|
||||||
|
['logging.ring_size', T('Linii logu w panelu', 'Log lines in panel'), 'number'], ['logging.stats_retention_days', T('Retencja statystyk (dni)', 'Stats retention (days)'), 'number'],
|
||||||
|
['logging.audit_retention_days', T('Retencja audytu (dni)', 'Audit retention (days)'), 'number'],
|
||||||
|
['panel.device_inactive_days', T('Usuwaj urządzenia nieaktywne (dni, 0 = nigdy)', 'Remove inactive devices (days, 0 = never)'), 'number'],
|
||||||
|
]));
|
||||||
|
|
||||||
|
const POLICY_FIELDS = () => [
|
||||||
|
['devpwenabled', T('Wymagaj hasła urządzenia', 'Require device password'), 'bool'], ['alphanumpwreq', T('Hasło alfanumeryczne', 'Alphanumeric password'), 'bool'],
|
||||||
|
['allowsimpledevpw', T('Pozwól na proste hasła', 'Allow simple passwords'), 'bool'], ['mindevpwlenngth', T('Min. długość hasła', 'Min password length'), 'number'],
|
||||||
|
['mindevcomplexchars', T('Min. znaków złożonych', 'Min complex characters'), 'number'], ['maxinacttimedevlock', T('Blokada po bezczynności (s)', 'Lock after inactivity (s)'), 'number'],
|
||||||
|
['maxdevpwfailedattempts', T('Wymaż po N błędnych hasłach', 'Wipe after N failed passwords'), 'number'], ['devpwexpiration', T('Wygaśnięcie hasła (dni)', 'Password expiration (days)'), 'number'],
|
||||||
|
['devpwhistory', T('Historia haseł', 'Password history'), 'number'], ['pwrecoveryenabled', T('Odzyskiwanie hasła', 'Password recovery'), 'bool'],
|
||||||
|
['devencenabled', T('Szyfrowanie urządzenia', 'Device encryption'), 'bool'], ['reqdevenc', T('Wymagaj szyfrowania', 'Require encryption'), 'bool'],
|
||||||
|
['attenabled', T('Załączniki dozwolone', 'Attachments allowed'), 'bool'], ['maxattsize', T('Maks. załącznik (bajty, -1 = bez limitu)', 'Max attachment (bytes, -1 unlimited)'), 'number'],
|
||||||
|
['allowhtmlemail', T('Poczta HTML', 'HTML email'), 'bool'], ['maxemailagefilter', T('Maks. wiek poczty (0-5)', 'Max email age filter'), 'number'],
|
||||||
|
['maxcalagefilter', T('Maks. wiek kalendarza (0,4-7)', 'Max calendar age filter'), 'number'], ['maxemailbodytruncsize', T('Obcięcie treści (bajty, -1)', 'Body truncation (bytes, -1)'), 'number'],
|
||||||
|
['maxemailhtmlbodytruncsize', T('Obcięcie HTML (bajty, -1)', 'HTML truncation (bytes, -1)'), 'number'], ['allowstoragecard', T('Karta pamięci', 'Storage card'), 'bool'],
|
||||||
|
['allowcam', T('Aparat', 'Camera'), 'bool'], ['allowwifi', 'Wi-Fi', 'bool'], ['allowbluetooth', 'Bluetooth (0/1/2)', 'number'], ['allowirda', 'IrDA', 'bool'],
|
||||||
|
['allowtextmessaging', 'SMS', 'bool'], ['allowpopimapemail', 'POP/IMAP', 'bool'], ['allowbrowser', T('Przeglądarka', 'Browser'), 'bool'],
|
||||||
|
['allowconsumeremail', T('Prywatna poczta', 'Consumer email'), 'bool'], ['allowinternetsharing', T('Udostępnianie internetu', 'Internet sharing'), 'bool'],
|
||||||
|
['allowremotedesktop', T('Pulpit zdalny', 'Remote desktop'), 'bool'], ['allowdesktopsync', T('Synchronizacja z PC', 'Desktop sync'), 'bool'],
|
||||||
|
['allowunsignedapps', T('Niepodpisane aplikacje', 'Unsigned apps'), 'bool'], ['allowunsignedinstallationpackages', T('Niepodpisane pakiety', 'Unsigned packages'), 'bool'],
|
||||||
|
['reqmansyncroam', T('Ręczna synchronizacja w roamingu', 'Manual sync when roaming'), 'bool'], ['reqsignedsmimemessages', T('Wymagaj podpisu S/MIME', 'Require signed S/MIME'), 'bool'],
|
||||||
|
['reqencsmimemessages', T('Wymagaj szyfrowania S/MIME', 'Require encrypted S/MIME'), 'bool'], ['allowsmimesoftcerts', T('Certyfikaty programowe S/MIME', 'S/MIME soft certs'), 'bool'],
|
||||||
|
];
|
||||||
|
|
||||||
|
PAGES.policies = async (v) => {
|
||||||
|
let sel = 0;
|
||||||
|
await settingsPage(v, 'PROVISIONING', T('Polityki urządzeń', 'Device policies'), T('Polityki bezpieczeństwa przekazywane urządzeniom (odpowiednik policies.ini). Zmiana polityki wymusza ponowny provisioning urządzeń.', 'Security policies pushed to devices (policies.ini equivalent). Changing a policy forces devices to re-provision.'), (s) => {
|
||||||
|
const pols = s.provisioning.policies;
|
||||||
|
return section(s, 'Provisioning', '', [
|
||||||
|
['provisioning.enabled', T('Wymuszaj provisioning', 'Enforce provisioning'), 'bool'],
|
||||||
|
['provisioning.loose', T('Luźny provisioning (urządzenia bez obsługi polityk)', 'Loose provisioning (devices without policy support)'), 'bool'],
|
||||||
|
['provisioning.default_policy', T('Domyślna polityka', 'Default policy'), 'select', { options: pols.map((p) => [p.name, p.name]) }],
|
||||||
|
]) + `<div class="card"><div class="section-title"><h2>${T('Polityka', 'Policy')}</h2><div class="top-actions"><select id="pol-sel">${pols.map((p, i) => `<option value="${i}">${esc(p.name)}</option>`).join('')}</select><button class="quiet" id="pol-add">${T('Duplikuj', 'Duplicate')}</button><button class="danger" id="pol-del">${T('Usuń', 'Delete')}</button></div></div><div id="pol-form"></div></div>
|
||||||
|
<div class="card"><div class="section-title"><div><h2>${T('Przypisania użytkowników', 'User assignments')}</h2><p class="fine">${T('Jedna linia: login=nazwa_polityki', 'One per line: login=policy_name')}</p></div></div><textarea id="pol-users" spellcheck="false">${esc(s.provisioning.user_policies.map((u) => `${u.username}=${u.policy}`).join('\n'))}</textarea></div>`;
|
||||||
|
}, {
|
||||||
|
bind(form, s) {
|
||||||
|
const pf = $('#pol-form', form);
|
||||||
|
const drawPol = () => {
|
||||||
|
const p = s.provisioning.policies[sel];
|
||||||
|
pf.innerHTML = `<div class="form-grid">${fieldHtml({ p }, ['p.name', T('Nazwa', 'Name'), 'text'])}${POLICY_FIELDS().map((f) => fieldHtml({ p }, [`p.${f[0]}`, f[1], f[2]])).join('')}</div>`;
|
||||||
|
$$('[data-path]', pf).forEach((el) => { el.dataset.pol = el.dataset.path.slice(2); el.removeAttribute('data-path'); });
|
||||||
|
};
|
||||||
|
const keep = () => {
|
||||||
|
const p = s.provisioning.policies[sel];
|
||||||
|
$$('[data-pol]', pf).forEach((el) => {
|
||||||
|
const k = el.dataset.pol;
|
||||||
|
p[k] = el.type === 'checkbox' ? el.checked : el.type === 'number' ? Number(el.value) : el.value.trim();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
$('#pol-sel', form).addEventListener('change', (e) => { keep(); sel = Number(e.target.value); drawPol(); });
|
||||||
|
$('#pol-add', form).addEventListener('click', () => {
|
||||||
|
keep();
|
||||||
|
const c = JSON.parse(JSON.stringify(s.provisioning.policies[sel]));
|
||||||
|
c.name = `${c.name}-copy`;
|
||||||
|
s.provisioning.policies.push(c);
|
||||||
|
sel = s.provisioning.policies.length - 1;
|
||||||
|
$('#pol-sel', form).insertAdjacentHTML('beforeend', `<option value="${sel}">${esc(c.name)}</option>`);
|
||||||
|
$('#pol-sel', form).value = String(sel);
|
||||||
|
drawPol();
|
||||||
|
$('#dirty').hidden = false;
|
||||||
|
});
|
||||||
|
$('#pol-del', form).addEventListener('click', () => {
|
||||||
|
if (s.provisioning.policies.length < 2) { toast(T('Musi zostać przynajmniej jedna polityka', 'At least one policy is required'), true); return; }
|
||||||
|
s.provisioning.policies.splice(sel, 1);
|
||||||
|
sel = 0;
|
||||||
|
$('#pol-sel', form).innerHTML = s.provisioning.policies.map((p, i) => `<option value="${i}">${esc(p.name)}</option>`).join('');
|
||||||
|
drawPol();
|
||||||
|
$('#dirty').hidden = false;
|
||||||
|
});
|
||||||
|
drawPol();
|
||||||
|
this.keep = keep;
|
||||||
|
this.s = s;
|
||||||
|
},
|
||||||
|
collect(form, cur) {
|
||||||
|
this.keep();
|
||||||
|
cur.provisioning.policies = this.s.provisioning.policies;
|
||||||
|
cur.provisioning.user_policies = $('#pol-users', form).value.split('\n').map((l) => l.trim()).filter((l) => l.includes('=')).map((l) => { const [u, p] = l.split('='); return { username: u.trim(), policy: p.trim() }; });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
PAGES.security = async (v) => {
|
||||||
|
await settingsPage(v, 'HARDENING', T('Bezpieczeństwo', 'Security'), T('Ochrona przed zgadywaniem haseł, kontrola dostępu sieciowego, urządzeń i panelu.', 'Brute-force protection, network/device access control and panel security.'), (s) =>
|
||||||
|
section(s, T('Ochrona logowania', 'Login protection'), T('Po przekroczeniu limitu konto (i IP przy 3× limicie) jest blokowane. Linia logu jest zgodna z fail2ban.', 'Exceeding the limit locks the account (and the IP at 3× the limit). The log line is fail2ban friendly.'), [
|
||||||
|
['security.auth_fail_max', T('Maks. błędnych logowań', 'Max failed logins'), 'number'], ['security.auth_fail_window_secs', T('Okno (s)', 'Window (s)'), 'number'],
|
||||||
|
['security.lockout_secs', T('Czas blokady (s)', 'Lockout (s)'), 'number'], ['security.auth_cache_secs', T('Cache poprawnego logowania (s)', 'Successful login cache (s)'), 'number', { help: T('Przechowywany jest tylko HMAC, nigdy hasło.', 'Only an HMAC is kept, never the password.') }],
|
||||||
|
['security.log_auth_failures', T('Loguj nieudane logowania', 'Log failed logins'), 'bool'],
|
||||||
|
]) +
|
||||||
|
section(s, T('Sieć', 'Network'), '', [
|
||||||
|
['security.trusted_proxies', T('Zaufane proxy (CIDR)', 'Trusted proxies (CIDR)'), 'list', { help: T('Tylko od nich akceptowany jest nagłówek z prawdziwym IP klienta.', 'Only these may set the real client IP header.') }],
|
||||||
|
['security.real_ip_header', T('Nagłówek z IP klienta', 'Client IP header'), 'text'],
|
||||||
|
['security.eas_allowed_networks', T('Sieci z dostępem do ActiveSync (puste = wszystkie)', 'Networks allowed to use ActiveSync (empty = all)'), 'list'],
|
||||||
|
['panel.allowed_networks', T('Sieci z dostępem do panelu (puste = wszystkie)', 'Networks allowed to use the panel (empty = all)'), 'list'],
|
||||||
|
]) +
|
||||||
|
section(s, T('Użytkownicy i urządzenia', 'Users & devices'), '', [
|
||||||
|
['security.require_device_approval', T('Nowe urządzenia wymagają zatwierdzenia', 'New devices require approval'), 'bool'],
|
||||||
|
['security.allowed_users', T('Tylko ci użytkownicy (puste = wszyscy)', 'Only these users (empty = all)'), 'list'],
|
||||||
|
['security.blocked_users', T('Zablokowani użytkownicy', 'Blocked users'), 'list'],
|
||||||
|
['security.blocked_device_types', T('Zablokowane typy urządzeń / User-Agent', 'Blocked device types / User-Agent'), 'list', { ph: 'WindowsMail\nOutlook' }],
|
||||||
|
]) +
|
||||||
|
section(s, T('Panel', 'Panel'), '', [
|
||||||
|
['panel.session_ttl_minutes', T('Wygasanie sesji (min)', 'Session lifetime (min)'), 'number'],
|
||||||
|
['panel.prometheus_token', T('Token Prometheus (/metrics)', 'Prometheus token (/metrics)'), 'secret', { help: T('Puste = endpoint wyłączony. Użycie: Authorization: Bearer <token>', 'Empty = endpoint disabled. Use: Authorization: Bearer <token>') }],
|
||||||
|
]) +
|
||||||
|
`<div class="card"><div class="section-title"><h2>${T('Konto administratora', 'Administrator account')}</h2></div>
|
||||||
|
<div class="form-grid"><div class="field"><label>${T('Obecne hasło', 'Current password')}</label><input type="password" id="pw-cur" autocomplete="current-password"></div>
|
||||||
|
<div class="field"><label>${T('Nowe hasło (min. 12 znaków)', 'New password (min. 12 chars)')}</label><input type="password" id="pw-new" autocomplete="new-password"></div>
|
||||||
|
<div class="field"><label>${T('Powtórz', 'Repeat')}</label><input type="password" id="pw-new2" autocomplete="new-password"></div></div>
|
||||||
|
<div class="form-actions"><button class="quiet" id="revoke">${T('Wyloguj inne sesje', 'Sign out other sessions')}</button><button class="outline" id="pw-save">${T('Zmień hasło', 'Change password')}</button></div></div>`,
|
||||||
|
{
|
||||||
|
bind(form) {
|
||||||
|
$('#pw-save', form).addEventListener('click', async () => {
|
||||||
|
const n = $('#pw-new', form).value;
|
||||||
|
if (n !== $('#pw-new2', form).value) { toast(T('Hasła się różnią', 'Passwords differ'), true); return; }
|
||||||
|
try { await api('POST', '/api/password', { current: $('#pw-cur', form).value, new: n }); toast(T('Hasło zmienione', 'Password changed')); $$('#pw-cur,#pw-new,#pw-new2', form).forEach((i) => { i.value = ''; }); } catch (e) { toast(e.message, true); }
|
||||||
|
});
|
||||||
|
$('#revoke', form).addEventListener('click', async () => { await api('POST', '/api/sessions/revoke-others'); toast(T('Inne sesje wylogowane', 'Other sessions revoked')); });
|
||||||
|
// password fields are not settings
|
||||||
|
$$('#pw-cur,#pw-new,#pw-new2', form).forEach((i) => i.addEventListener('input', (e) => e.stopPropagation()));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
PAGES.logs = async (v) => {
|
||||||
|
v.innerHTML = head(T('DIAGNOSTYKA', 'DIAGNOSTICS'), T('Logi i audyt', 'Logs & audit'), T('Ostatnie wpisy logu serwera oraz dziennik działań administracyjnych.', 'Recent server log lines and the administrative audit trail.')) +
|
||||||
|
`<div class="tabs"><button class="tab active" data-tab="log">${T('Log serwera', 'Server log')}</button><button class="tab" data-tab="audit">${T('Audyt', 'Audit')}</button></div>
|
||||||
|
<div class="card" id="tab-log"><div class="toolbar"><select id="lv"><option value="">${T('wszystkie', 'all')}</option><option value="INFO">INFO+</option><option value="WARN">WARN+</option><option value="ERROR">ERROR</option></select><input id="lq" placeholder="${T('szukaj', 'search')}"><label class="check"><input type="checkbox" id="lf" checked> ${T('auto-odświeżanie', 'auto refresh')}</label></div><div class="feed" id="logbox"></div></div>
|
||||||
|
<div class="card" id="tab-audit" hidden><div class="table-scroll" id="auditbox"></div></div>`;
|
||||||
|
$$('.tab', v).forEach((t) => t.addEventListener('click', () => {
|
||||||
|
$$('.tab', v).forEach((x) => x.classList.toggle('active', x === t));
|
||||||
|
$('#tab-log').hidden = t.dataset.tab !== 'log';
|
||||||
|
$('#tab-audit').hidden = t.dataset.tab !== 'audit';
|
||||||
|
}));
|
||||||
|
async function logs() {
|
||||||
|
if (!$('#lf').checked) return;
|
||||||
|
const q = new URLSearchParams({ n: '800' });
|
||||||
|
if ($('#lv').value) q.set('level', $('#lv').value);
|
||||||
|
if ($('#lq').value.trim()) q.set('q', $('#lq').value.trim());
|
||||||
|
const l = await api('GET', `/api/logs?${q}`);
|
||||||
|
const box = $('#logbox');
|
||||||
|
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 30;
|
||||||
|
box.innerHTML = l.map((x) => `<div class="feed-row"><span class="t">${esc(fmtTime(x.ts))}</span><span class="${x.level === 'ERROR' || x.level === 'WARN' ? 'err' : 'cmd'}">${esc(x.level)}</span><span>${esc(x.message)}</span><span></span><span></span></div>`).join('') || `<div class="empty">-</div>`;
|
||||||
|
if (atBottom) box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
async function audit() {
|
||||||
|
const a = await api('GET', '/api/audit');
|
||||||
|
$('#auditbox').innerHTML = a.length ? `<table><thead><tr><th>${T('Czas', 'Time')}</th><th>${T('Kto', 'Actor')}</th><th>${T('Akcja', 'Action')}</th><th>${T('Cel', 'Target')}</th><th>IP</th><th>${T('Szczegóły', 'Details')}</th></tr></thead><tbody>${a.map((e) => `<tr><td>${esc(fmtTs(e.ts))}</td><td>${esc(e.actor)}</td><td>${esc(e.action)}</td><td>${esc(e.target)}</td><td>${esc(e.ip)}</td><td>${esc(e.details)}</td></tr>`).join('')}</tbody></table>` : `<div class="empty">-</div>`;
|
||||||
|
}
|
||||||
|
$('#lv').addEventListener('change', logs);
|
||||||
|
$('#lq').addEventListener('input', () => { clearTimeout(PAGES._lq); PAGES._lq = setTimeout(logs, 300); });
|
||||||
|
every(3000, () => logs().catch(() => {}));
|
||||||
|
every(30000, () => audit().catch(() => {}));
|
||||||
|
};
|
||||||
|
|
||||||
|
PAGES.about = async (v) => {
|
||||||
|
const o = await api('GET', '/api/overview');
|
||||||
|
v.innerHTML = head('ABOUT', 'ActivePush', T('Serwer Exchange ActiveSync dla IMAP/SMTP + CalDAV/CardDAV - przepisany z Z-Push na Rust.', 'Exchange ActiveSync server for IMAP/SMTP + CalDAV/CardDAV - Z-Push rewritten in Rust.')) +
|
||||||
|
`<div class="grid two"><div class="card"><h2>${T('Wersja', 'Version')} ${esc(o.version)}</h2><dl class="kv">
|
||||||
|
<dt>ActiveSync</dt><dd>2.5, 12.0, 12.1, 14.0, 14.1</dd>
|
||||||
|
<dt>${T('Komendy', 'Commands')}</dt><dd>Sync, FolderSync, FolderCreate/Update/Delete, Ping, Provision, SendMail, SmartReply, SmartForward, GetAttachment, ItemOperations, GetItemEstimate, MoveItems, MeetingResponse, Search (GAL, Mailbox), ResolveRecipients, Settings, ValidateCert</dd>
|
||||||
|
<dt>Endpoints</dt><dd>/Microsoft-Server-ActiveSync<br>/autodiscover/autodiscover.xml (+ AutoDiscover/AutoDiscover.xml, Autodiscover/Autodiscover.xml)</dd>
|
||||||
|
<dt>CLI</dt><dd>active-push admin | config | device | probe</dd></dl></div>
|
||||||
|
<div class="card"><h2>nginx</h2><p class="fine">${T('Zastępuje lokalizacje fastcgi Z-Push:', 'Replaces the Z-Push fastcgi locations:')}</p><pre class="command">location = /Microsoft-Server-ActiveSync {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3660s;
|
||||||
|
proxy_buffering off;
|
||||||
|
client_max_body_size 64m;
|
||||||
|
}
|
||||||
|
location ~* ^/autodiscover/autodiscover\\.xml$ {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
}</pre></div></div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ auth / boot
|
||||||
|
function setAuthTexts(setup) {
|
||||||
|
$('#auth-eyebrow').textContent = setup ? T('PIERWSZE URUCHOMIENIE', 'FIRST RUN') : T('ADMINISTRACJA', 'ADMINISTRATION');
|
||||||
|
$('#auth-title').textContent = setup ? T('Utwórz administratora', 'Create administrator') : T('Zaloguj się', 'Sign in');
|
||||||
|
$('#setup-help').textContent = T('Wygeneruj jednorazowy token na serwerze i wklej go poniżej:', 'Generate a one-time token on the server and paste it below:');
|
||||||
|
$('#lbl-token').textContent = T('Token instalacyjny', 'Setup token');
|
||||||
|
$('#lbl-user').textContent = T('Login', 'Username');
|
||||||
|
$('#lbl-pass').textContent = setup ? T('Hasło (min. 12 znaków)', 'Password (min. 12 characters)') : T('Hasło', 'Password');
|
||||||
|
$('#lbl-pass2').textContent = T('Powtórz hasło', 'Repeat password');
|
||||||
|
$('#auth-submit').textContent = setup ? T('Utwórz i zaloguj', 'Create & sign in') : T('Zaloguj', 'Sign in');
|
||||||
|
$('#setup-fields').hidden = !setup;
|
||||||
|
$('#confirm-wrap').hidden = !setup;
|
||||||
|
$('#password').autocomplete = setup ? 'new-password' : 'current-password';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAuth(setup) {
|
||||||
|
clearTimers();
|
||||||
|
$('#loading').hidden = true;
|
||||||
|
$('#shell').hidden = true;
|
||||||
|
$('#auth').hidden = false;
|
||||||
|
S.setup = !!setup;
|
||||||
|
setAuthTexts(S.setup);
|
||||||
|
$('#username').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
let sess;
|
||||||
|
try { sess = await api('GET', '/api/session'); } catch (e) { $('#loading').textContent = e.message; return; }
|
||||||
|
if (!sess.authenticated) { showAuth(sess.setup_required); return; }
|
||||||
|
S.session = sess;
|
||||||
|
S.csrf = sess.csrf;
|
||||||
|
$('#loading').hidden = true;
|
||||||
|
$('#auth').hidden = true;
|
||||||
|
$('#shell').hidden = false;
|
||||||
|
$('#whoami').textContent = sess.username;
|
||||||
|
$('#logout').textContent = T('Zablokuj', 'Lock');
|
||||||
|
$('#theme').title = T('Motyw jasny/ciemny', 'Light/dark theme');
|
||||||
|
document.title = `${sess.server_name || 'ActivePush'} / Control room`;
|
||||||
|
go();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#auth-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const msg = $('#auth-msg');
|
||||||
|
msg.textContent = '';
|
||||||
|
const body = { username: $('#username').value.trim(), password: $('#password').value, token: $('#setup-token').value.trim() };
|
||||||
|
if (S.setup && body.password !== $('#password2').value) { msg.textContent = T('Hasła się różnią', 'Passwords differ'); return; }
|
||||||
|
$('#auth-submit').disabled = true;
|
||||||
|
try {
|
||||||
|
await api('POST', S.setup ? '/api/setup' : '/api/login', body);
|
||||||
|
$('#password').value = '';
|
||||||
|
$('#password2').value = '';
|
||||||
|
await boot();
|
||||||
|
} catch (err) { msg.textContent = err.message; } finally { $('#auth-submit').disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#logout').addEventListener('click', async () => { try { await api('POST', '/api/logout'); } catch { /* ignore */ } showAuth(false); });
|
||||||
|
$('#menu-toggle').addEventListener('click', () => $('#nav').classList.toggle('open'));
|
||||||
|
const theme = store.get('ap.theme', 'dark');
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
$('#theme').addEventListener('click', () => {
|
||||||
|
const t = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.dataset.theme = t;
|
||||||
|
store.set('ap.theme', t);
|
||||||
|
if (S.session) go();
|
||||||
|
});
|
||||||
|
const langSel = $('#lang');
|
||||||
|
langSel.value = LANG;
|
||||||
|
document.documentElement.lang = LANG;
|
||||||
|
langSel.addEventListener('change', () => { LANG = langSel.value; store.set('ap.lang', LANG); location.reload(); });
|
||||||
|
window.addEventListener('hashchange', () => { if (S.session) go(); });
|
||||||
|
let resizeT;
|
||||||
|
window.addEventListener('resize', () => { clearTimeout(resizeT); resizeT = setTimeout(() => { if (S.session && S.page === 'overview') go(); }, 400); });
|
||||||
|
boot();
|
||||||
|
})();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#1b1f1d"/><text x="32" y="41" text-anchor="middle" font-family="monospace" font-size="24" font-weight="700" fill="#f69b74">a<tspan fill="#cfe0ac">/</tspan>p</text></svg>
|
||||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1,74 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>ActivePush / Control room</title>
|
||||||
|
<link rel="icon" href="/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<script defer src="/app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-loading" id="loading"><div><div class="spinner"></div>ACTIVEPUSH</div></div>
|
||||||
|
|
||||||
|
<div class="auth-page" id="auth" hidden>
|
||||||
|
<div class="card auth-card">
|
||||||
|
<div class="brand"><span class="brand-mark">a<span>/</span>p</span><span>ActivePush</span></div>
|
||||||
|
<div class="small-label" id="auth-eyebrow">ADMINISTRATION</div>
|
||||||
|
<h1 id="auth-title">Sign in</h1>
|
||||||
|
<form id="auth-form" autocomplete="on">
|
||||||
|
<div id="setup-fields" hidden>
|
||||||
|
<p class="fine" id="setup-help"></p>
|
||||||
|
<code class="command">sudo -u active-push active-push admin setup-token</code>
|
||||||
|
<label for="setup-token" id="lbl-token">Setup token</label>
|
||||||
|
<input id="setup-token" autocomplete="off" spellcheck="false" maxlength="64">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="username" id="lbl-user">Username</label>
|
||||||
|
<input id="username" autocomplete="username" autocapitalize="none" spellcheck="false" maxlength="64" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" id="lbl-pass">Password</label>
|
||||||
|
<input id="password" type="password" autocomplete="current-password" maxlength="256" required>
|
||||||
|
</div>
|
||||||
|
<div id="confirm-wrap" hidden>
|
||||||
|
<label for="password2" id="lbl-pass2">Repeat password</label>
|
||||||
|
<input id="password2" type="password" autocomplete="new-password" maxlength="256">
|
||||||
|
</div>
|
||||||
|
<button class="primary" type="submit" id="auth-submit">Sign in</button>
|
||||||
|
<p class="fine" id="auth-msg" role="alert"></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shell" id="shell" hidden>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<a class="brand" href="#overview"><span class="brand-mark">a<span>/</span>p</span><span>ActivePush</span></a>
|
||||||
|
<button class="quiet mobile-toggle" id="menu-toggle" type="button">Menu</button>
|
||||||
|
<div class="edition">EXCHANGE ACTIVESYNC GATEWAY</div>
|
||||||
|
<nav class="main-navigation" id="nav"></nav>
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<span class="small-label">RUNTIME</span>
|
||||||
|
<p>Rust / Tokio / Axum<br>IMAP · CalDAV · CardDAV<br>SQLite</p>
|
||||||
|
<div class="local-tag"><span class="signal pulse"></span><span id="push-count">PUSH</span></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main>
|
||||||
|
<header class="topbar">
|
||||||
|
<span class="breadcrumb">CONTROL ROOM <span>/</span> <b id="page-label">Overview</b></span>
|
||||||
|
<div class="top-actions">
|
||||||
|
<select id="lang" class="quiet" aria-label="Language"><option value="en">EN</option><option value="pl">PL</option></select>
|
||||||
|
<button class="quiet" id="theme" type="button">◐</button>
|
||||||
|
<span class="fine mono" id="whoami"></span>
|
||||||
|
<button class="quiet" id="logout" type="button">Lock</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="view"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-root"></div>
|
||||||
|
<div class="toast" id="toast" hidden></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
/* ActivePush control room - visual language shared with RustProxy */
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #131615;
|
||||||
|
--panel: #1b1f1d;
|
||||||
|
--panel2: #202622;
|
||||||
|
--line: #323a34;
|
||||||
|
--row-line: #2b332c;
|
||||||
|
--muted: #a0aaa0;
|
||||||
|
--subtle: #879082;
|
||||||
|
--text: #edf0e9;
|
||||||
|
--accent: #f69b74;
|
||||||
|
--accent-hover: #ffb090;
|
||||||
|
--accent-ink: #221810;
|
||||||
|
--green: #bdd293;
|
||||||
|
--red: #ffaaa2;
|
||||||
|
--blue: #8ebcff;
|
||||||
|
--violet: #d7adff;
|
||||||
|
--sand: #c9b99c;
|
||||||
|
--sidebar-bg: #101310;
|
||||||
|
--brand2: #cfe0ac;
|
||||||
|
--nav-active: #242b23;
|
||||||
|
--nav-hover: #20251f;
|
||||||
|
--field-bg: #131813;
|
||||||
|
--field-line: #47523f;
|
||||||
|
--outline: #717b67;
|
||||||
|
--grid: #333c33;
|
||||||
|
--code-bg: #101810;
|
||||||
|
--notice-bg: #29251c;
|
||||||
|
--notice-line: #554833;
|
||||||
|
--pill-line: #6d5a45;
|
||||||
|
--pill-live: #576847;
|
||||||
|
--danger-line: #79514e;
|
||||||
|
--shadow: #0009;
|
||||||
|
--backdrop: rgba(0, 0, 0, .68);
|
||||||
|
--r-surface: 6px;
|
||||||
|
--r-control: 4px;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Consolas, monospace;
|
||||||
|
--sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme=light] {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f4f6f2;
|
||||||
|
--panel: #fff;
|
||||||
|
--panel2: #edf1eb;
|
||||||
|
--line: #cbd3c7;
|
||||||
|
--row-line: #e0e5dd;
|
||||||
|
--muted: #5e685f;
|
||||||
|
--subtle: #687369;
|
||||||
|
--text: #1d241f;
|
||||||
|
--accent: #b85b38;
|
||||||
|
--accent-hover: #a84f30;
|
||||||
|
--accent-ink: #fff;
|
||||||
|
--green: #5f783f;
|
||||||
|
--red: #b64a43;
|
||||||
|
--blue: #4f77a9;
|
||||||
|
--violet: #7d4fa3;
|
||||||
|
--sand: #685b45;
|
||||||
|
--sidebar-bg: #eef2ec;
|
||||||
|
--brand2: #6b8249;
|
||||||
|
--nav-active: #e1e8dc;
|
||||||
|
--nav-hover: #e8ede6;
|
||||||
|
--field-bg: #fff;
|
||||||
|
--field-line: #b7c1b5;
|
||||||
|
--outline: #98a497;
|
||||||
|
--grid: #d4dbd1;
|
||||||
|
--code-bg: #edf3e9;
|
||||||
|
--notice-bg: #fff9ef;
|
||||||
|
--notice-line: #d4c2a5;
|
||||||
|
--pill-line: #cbb28f;
|
||||||
|
--pill-live: #a8ba8d;
|
||||||
|
--danger-line: #d7a29d;
|
||||||
|
--shadow: rgba(31, 39, 33, .22);
|
||||||
|
--backdrop: rgba(31, 39, 33, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; scrollbar-color: #4f5d50 transparent; scrollbar-width: thin; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
html, body { margin: 0; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 12px/1.5 var(--sans);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
code, .mono { font-family: var(--mono); }
|
||||||
|
h1 { margin: 0; font-size: clamp(26px, 3vw, 40px); font-weight: 520; letter-spacing: -1.5px; line-height: 1.1; }
|
||||||
|
h2 { margin: 0; font-size: 15px; letter-spacing: -.3px; }
|
||||||
|
h3 { margin: 0 0 8px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- controls */
|
||||||
|
button { font: inherit; cursor: pointer; border: 0; }
|
||||||
|
button:disabled { opacity: .5; cursor: wait; }
|
||||||
|
.primary, .outline, .quiet, .danger {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
min-height: 27px; padding: 5px 11px; font-size: 11px; border-radius: var(--r-control); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.primary { background: var(--accent); color: var(--accent-ink); border: 1px solid var(--accent); font-weight: 650; }
|
||||||
|
.primary:hover { background: var(--accent-hover); }
|
||||||
|
.outline { background: transparent; color: var(--text); border: 1px solid var(--outline); }
|
||||||
|
.outline:hover { border-color: var(--accent); }
|
||||||
|
.quiet { background: transparent; color: var(--muted); border: 1px solid var(--line); }
|
||||||
|
.quiet:hover { color: var(--text); border-color: var(--outline); }
|
||||||
|
.danger { background: transparent; color: var(--red); border: 1px solid var(--danger-line); }
|
||||||
|
.danger:hover { background: color-mix(in srgb, var(--red) 12%, transparent); }
|
||||||
|
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||||
|
outline: 2px solid var(--accent); outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
font: inherit; font-size: 12px; width: 100%; min-width: 0;
|
||||||
|
background: var(--field-bg); color: var(--text);
|
||||||
|
border: 1px solid var(--field-line); border-radius: var(--r-control);
|
||||||
|
padding: 6px 8px; min-height: 29px;
|
||||||
|
}
|
||||||
|
textarea { min-height: 70px; resize: vertical; font-family: var(--mono); }
|
||||||
|
input[type=checkbox] { width: 15px; height: 15px; min-height: 0; accent-color: var(--accent); }
|
||||||
|
label { display: block; color: var(--muted); font-size: 11px; margin: 0 0 3px; }
|
||||||
|
|
||||||
|
.small-label { font: 9px var(--mono); letter-spacing: 1.2px; color: var(--muted); text-transform: uppercase; }
|
||||||
|
.fine { font-size: 11px; color: var(--muted); line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
.pill {
|
||||||
|
display: inline-block; padding: 2px 7px; border: 1px solid var(--pill-line); color: var(--accent);
|
||||||
|
font: 9px var(--mono); letter-spacing: .8px; border-radius: 3px; white-space: nowrap; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.pill.live, .pill.ok { border-color: var(--pill-live); color: var(--green); }
|
||||||
|
.pill.bad { border-color: var(--danger-line); color: var(--red); }
|
||||||
|
.pill.info { border-color: color-mix(in srgb, var(--blue) 45%, transparent); color: var(--blue); }
|
||||||
|
.pill.muted { border-color: var(--line); color: var(--muted); }
|
||||||
|
.signal { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; }
|
||||||
|
.signal.pulse { animation: pulse 1.6s infinite; }
|
||||||
|
@keyframes pulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 60%, transparent); } 70% { box-shadow: 0 0 0 7px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
display: flex; gap: 12px; align-items: flex-start; color: var(--sand); border: 1px solid var(--notice-line);
|
||||||
|
background: var(--notice-bg); border-radius: var(--r-surface); padding: 8px 11px; margin-bottom: 10px; font-size: 11px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.notice b { font-family: var(--mono); color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- shell */
|
||||||
|
.shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); min-height: 100vh; }
|
||||||
|
.sidebar {
|
||||||
|
background: var(--sidebar-bg); border-right: 1px solid var(--line); padding: 18px 12px;
|
||||||
|
display: flex; flex-direction: column; gap: 14px; position: sticky; top: 0; height: 100vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 14px; letter-spacing: -.2px; }
|
||||||
|
.brand-mark {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; white-space: nowrap; width: 34px; height: 34px; border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; font: 700 12px var(--mono); color: var(--accent); background: var(--panel);
|
||||||
|
}
|
||||||
|
.brand-mark span { color: var(--brand2); }
|
||||||
|
.edition { font: 9px var(--mono); letter-spacing: 1.6px; color: var(--subtle); }
|
||||||
|
.main-navigation { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.nav {
|
||||||
|
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; background: transparent;
|
||||||
|
color: var(--muted); padding: 7px 9px; border-radius: var(--r-control); font-size: 12px;
|
||||||
|
}
|
||||||
|
.nav span { font: 10px var(--mono); color: var(--subtle); width: 18px; }
|
||||||
|
.nav b { font-weight: 500; }
|
||||||
|
.nav:hover { background: var(--nav-hover); color: var(--text); }
|
||||||
|
.nav.active { background: var(--nav-active); color: var(--brand2); }
|
||||||
|
.nav.active span { color: var(--accent); }
|
||||||
|
.nav .count { margin-left: auto; font: 10px var(--mono); color: var(--accent); }
|
||||||
|
.sidebar-bottom { margin-top: auto; border-top: 1px solid var(--line); padding-top: 12px; }
|
||||||
|
.sidebar-bottom p { margin: 6px 0; color: var(--subtle); font: 10px/1.6 var(--mono); }
|
||||||
|
.local-tag { display: flex; align-items: center; gap: 6px; font: 9px var(--mono); letter-spacing: 1.2px; color: var(--green); }
|
||||||
|
.mobile-toggle { display: none; }
|
||||||
|
|
||||||
|
main { min-width: 0; padding: 0 24px 40px; }
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 0;
|
||||||
|
border-bottom: 1px solid var(--line); margin-bottom: 18px; position: sticky; top: 0; background: var(--bg); z-index: 5;
|
||||||
|
}
|
||||||
|
.breadcrumb { font: 10px var(--mono); letter-spacing: 1.4px; color: var(--muted); }
|
||||||
|
.breadcrumb span { color: var(--subtle); margin: 0 4px; }
|
||||||
|
.breadcrumb b { color: var(--text); font-weight: 500; }
|
||||||
|
.top-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.top-actions select { width: auto; min-height: 27px; padding: 4px 8px; }
|
||||||
|
#theme { min-width: 30px; font-size: 13px; }
|
||||||
|
|
||||||
|
.page-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||||
|
.page-head .eyebrow { margin-bottom: 6px; }
|
||||||
|
.page-head p { margin: 6px 0 0; max-width: 720px; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- cards / grid */
|
||||||
|
.grid { display: grid; gap: 10px; }
|
||||||
|
.grid.stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
#view { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
#view > .page-head, #view > .tabs { margin-bottom: 0; }
|
||||||
|
.stack { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.stack > * + * { margin-top: 0; }
|
||||||
|
.grid.stats > .notice { grid-column: 1 / -1; margin: 0; }
|
||||||
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-surface); padding: 12px 14px; min-width: 0; }
|
||||||
|
.stack > * + * { margin-top: 10px; }
|
||||||
|
.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
|
||||||
|
.card-heading .small-label { display: block; margin-bottom: 2px; }
|
||||||
|
.stat { position: relative; overflow: hidden; }
|
||||||
|
.stat .value { font: 600 24px/1.15 var(--mono); letter-spacing: -1px; margin-top: 6px; }
|
||||||
|
.stat .value small { font-size: 12px; color: var(--muted); letter-spacing: 0; margin-left: 4px; }
|
||||||
|
.stat .sub { color: var(--muted); font-size: 10.5px; margin-top: 2px; }
|
||||||
|
.stat svg.spark { position: absolute; right: 0; bottom: 0; width: 55%; height: 34px; opacity: .55; pointer-events: none; }
|
||||||
|
.stat.accent .value { color: var(--accent); }
|
||||||
|
.stat.good .value { color: var(--green); }
|
||||||
|
.stat.bad .value { color: var(--red); }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- tables */
|
||||||
|
.table-scroll { overflow: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 11px; }
|
||||||
|
th {
|
||||||
|
font: 9.5px var(--mono); letter-spacing: .6px; color: var(--muted); font-weight: 400; text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid var(--line); padding: 7px 8px; text-align: left; white-space: nowrap; position: sticky; top: 0; background: var(--panel);
|
||||||
|
}
|
||||||
|
td { border-bottom: 1px solid var(--row-line); padding: 7px 8px; font-family: var(--mono); max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
tr.clickable { cursor: pointer; }
|
||||||
|
tr.clickable:hover td { background: var(--nav-hover); }
|
||||||
|
td.num, th.num { text-align: right; }
|
||||||
|
td .sub { display: block; color: var(--muted); font-size: 10px; }
|
||||||
|
.empty { color: var(--muted); padding: 18px 4px; text-align: center; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- forms */
|
||||||
|
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px 14px; }
|
||||||
|
.field.wide { grid-column: 1 / -1; }
|
||||||
|
.field .help { font-size: 10.5px; color: var(--subtle); margin-top: 3px; line-height: 1.45; }
|
||||||
|
.check { display: flex; align-items: center; gap: 8px; color: var(--text); font-size: 12px; margin-top: 18px; cursor: pointer; }
|
||||||
|
.check input { flex: 0 0 auto; }
|
||||||
|
.form-actions { display: flex; gap: 8px; align-items: center; justify-content: flex-end; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
.section-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin: 2px 0 10px; }
|
||||||
|
.dirty-flag { color: var(--accent); font: 10px var(--mono); }
|
||||||
|
.test-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: end; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line); }
|
||||||
|
.result { margin-top: 8px; font: 11px/1.5 var(--mono); padding: 7px 9px; border-radius: var(--r-control); background: var(--code-bg); border: 1px solid var(--line); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.result.ok { color: var(--green); }
|
||||||
|
.result.bad { color: var(--red); }
|
||||||
|
|
||||||
|
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.tab { background: transparent; color: var(--muted); padding: 7px 11px; border-bottom: 2px solid transparent; font-size: 12px; }
|
||||||
|
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
|
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.toolbar input, .toolbar select { width: auto; min-width: 180px; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- charts */
|
||||||
|
.chart { width: 100%; display: block; }
|
||||||
|
.chart text { fill: var(--muted); font: 9.5px var(--mono); }
|
||||||
|
.chart .grid-line { stroke: var(--grid); stroke-dasharray: 2 5; }
|
||||||
|
.chart-wrap { position: relative; }
|
||||||
|
.chart-tip {
|
||||||
|
position: absolute; pointer-events: none; background: var(--panel2); border: 1px solid var(--line); border-radius: var(--r-control);
|
||||||
|
padding: 5px 8px; font: 10.5px/1.5 var(--mono); box-shadow: 0 8px 20px var(--shadow); white-space: nowrap; z-index: 3;
|
||||||
|
}
|
||||||
|
.legend { display: flex; gap: 14px; flex-wrap: wrap; font: 10px var(--mono); color: var(--muted); margin-top: 6px; }
|
||||||
|
.legend i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
|
||||||
|
.hbar { display: grid; grid-template-columns: 120px 1fr 54px; gap: 8px; align-items: center; font: 11px var(--mono); margin: 5px 0; }
|
||||||
|
.hbar .track { height: 8px; background: var(--panel2); border-radius: 3px; overflow: hidden; }
|
||||||
|
.hbar .fill { height: 100%; background: var(--accent); border-radius: 3px; }
|
||||||
|
.hbar .n { text-align: right; color: var(--muted); }
|
||||||
|
.donut-layout { display: grid; grid-template-columns: 130px 1fr; gap: 14px; align-items: center; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- live feed */
|
||||||
|
.feed { font: 11px/1.55 var(--mono); max-height: 460px; overflow: auto; }
|
||||||
|
.feed-row { display: grid; grid-template-columns: 64px minmax(90px, 3fr) minmax(90px, 2fr) 72px 64px; gap: 10px; padding: 3px 4px; border-bottom: 1px solid var(--row-line); }
|
||||||
|
.feed-row.fresh { animation: flash 1.2s ease-out; }
|
||||||
|
@keyframes flash { from { background: color-mix(in srgb, var(--accent) 22%, transparent); } to { background: transparent; } }
|
||||||
|
.feed-row .t { color: var(--subtle); }
|
||||||
|
.feed-row .cmd { color: var(--accent); }
|
||||||
|
.feed-row .err { color: var(--red); }
|
||||||
|
.countdown { height: 3px; background: var(--panel2); border-radius: 2px; overflow: hidden; margin-top: 3px; }
|
||||||
|
.countdown div { height: 100%; background: var(--green); }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- dialog / toast */
|
||||||
|
.backdrop { position: fixed; inset: 0; background: var(--backdrop); z-index: 40; display: grid; place-items: center; padding: 16px; }
|
||||||
|
.dialog {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; width: min(980px, 100%);
|
||||||
|
max-height: calc(100vh - 32px); overflow: auto; box-shadow: 0 24px 60px var(--shadow); padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.dialog.small { width: min(460px, 100%); }
|
||||||
|
.dialog-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
|
||||||
|
.kv { display: grid; grid-template-columns: 150px 1fr; gap: 5px 12px; font-size: 11.5px; }
|
||||||
|
.kv dt { color: var(--muted); }
|
||||||
|
.kv dd { margin: 0; font-family: var(--mono); overflow-wrap: anywhere; }
|
||||||
|
.actions-row { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
|
||||||
|
.toast {
|
||||||
|
position: fixed; right: 18px; bottom: 18px; z-index: 60; background: #d0dfb4; color: #1c2818; border: 1px solid #ebf2de;
|
||||||
|
padding: 9px 13px; border-radius: 6px; box-shadow: 0 10px 28px var(--shadow); max-width: 420px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.toast.error { background: #ffd2bd; color: #42241b; border-color: #ff9f7a; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- auth */
|
||||||
|
.auth-page { display: grid; place-items: center; min-height: 100vh; padding: 16px; }
|
||||||
|
.auth-card { width: min(420px, 100%); }
|
||||||
|
.auth-card .brand { margin-bottom: 22px; }
|
||||||
|
.auth-card h1 { margin-bottom: 18px; }
|
||||||
|
.auth-card form > * + * { margin-top: 10px; }
|
||||||
|
.auth-card .primary { width: 100%; min-height: 34px; margin-top: 14px; }
|
||||||
|
.command { display: block; font: 11px var(--mono); background: var(--code-bg); border: 1px solid var(--line); padding: 7px 9px; border-radius: 4px; margin: 6px 0; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- loading */
|
||||||
|
.app-loading { display: grid; place-items: center; min-height: 100vh; color: var(--muted); font: 11px var(--mono); letter-spacing: 1.5px; }
|
||||||
|
.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 12px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- responsive */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.grid.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.grid.three { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
.sidebar { position: static; height: auto; padding: 10px 14px; flex-direction: row; flex-wrap: wrap; align-items: center; }
|
||||||
|
.sidebar .edition, .sidebar-bottom { display: none; }
|
||||||
|
.mobile-toggle { display: inline-flex; margin-left: auto; }
|
||||||
|
.main-navigation { display: none; width: 100%; }
|
||||||
|
.main-navigation.open { display: flex; }
|
||||||
|
main { padding: 0 14px 30px; }
|
||||||
|
.grid.two, .grid.three { grid-template-columns: 1fr; }
|
||||||
|
.grid.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.stat .value { font-size: 20px; }
|
||||||
|
.test-row { grid-template-columns: 1fr; }
|
||||||
|
.kv { grid-template-columns: 1fr; }
|
||||||
|
.feed-row { grid-template-columns: 56px 1fr 60px; }
|
||||||
|
.feed-row .u, .feed-row .ms { display: none; }
|
||||||
|
.donut-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* chart colour classes (CSP: no inline styles) */
|
||||||
|
.k1 { stroke: var(--accent); fill: var(--accent); }
|
||||||
|
.k2 { stroke: var(--green); fill: var(--green); }
|
||||||
|
.k3 { stroke: var(--blue); fill: var(--blue); }
|
||||||
|
.k4 { stroke: var(--violet); fill: var(--violet); }
|
||||||
|
.k5 { stroke: var(--red); fill: var(--red); }
|
||||||
|
.k6 { stroke: var(--sand); fill: var(--sand); }
|
||||||
|
.k7 { stroke: var(--muted); fill: var(--muted); }
|
||||||
|
.ln { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
|
||||||
|
.ar { stroke: none; fill-opacity: .14; }
|
||||||
|
.br { stroke: none; }
|
||||||
|
.dn { fill: none; stroke-width: 14; }
|
||||||
|
.cursor { stroke: var(--outline); stroke-dasharray: 3 3; }
|
||||||
|
.sw.k1, .fill.k1 { background: var(--accent); }
|
||||||
|
.sw.k2, .fill.k2 { background: var(--green); }
|
||||||
|
.sw.k3, .fill.k3 { background: var(--blue); }
|
||||||
|
.sw.k4, .fill.k4 { background: var(--violet); }
|
||||||
|
.sw.k5, .fill.k5 { background: var(--red); }
|
||||||
|
.sw.k6, .fill.k6 { background: var(--sand); }
|
||||||
|
.sw.k7, .fill.k7 { background: var(--muted); }
|
||||||
|
.donut-center { fill: var(--text); font: 600 15px var(--mono); }
|
||||||
|
.mt { margin-top: 14px; }
|
||||||
|
|
||||||
|
.card-heading input { max-width: 340px; }
|
||||||
|
|
||||||
|
/* select dropdown colours (native popups ignore transparent backgrounds) */
|
||||||
|
select option, select optgroup { background: var(--panel); color: var(--text); }
|
||||||
|
select.quiet { background: var(--panel); color: var(--text); }
|
||||||
|
.feed-row > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px; margin: 1px 4px 1px 0; padding: 1px 7px; border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; background: var(--panel2); color: var(--text); font: 10.5px var(--mono); cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip:hover { border-color: var(--accent); }
|
||||||
|
.chip .signal { margin: 0; width: 6px; height: 6px; }
|
||||||
|
td.wrap { white-space: normal; max-width: 420px; }
|
||||||