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; }
|
||||