Initial commit

This commit is contained in:
Maurus Decimus
2026-05-29 18:02:15 +02:00
commit 576073f8c9
263 changed files with 78638 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
name: Release
permissions:
"contents": "write"
on:
pull_request:
push:
tags:
- '**[0-9]+.[0-9]+.[0-9]+*'
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
plan:
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.plan.outputs.manifest }}
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
publishing: ${{ !github.event.pull_request }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install dist
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
- id: plan
run: |
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
echo "dist ran successfully"
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
build-local-artifacts:
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
needs:
- plan
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container && matrix.container.image || null }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
AWS_LC_SYS_PREBUILT_NASM: 1
permissions:
"attestations": "write"
"contents": "read"
"id-token": "write"
steps:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install Rust non-interactively if not already installed
if: ${{ matrix.container }}
run: |
if ! command -v cargo > /dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Install dist
run: ${{ matrix.install_dist.run }}
- name: Fetch local artifacts
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- name: Install dependencies
run: |
${{ matrix.packages_install }}
- name: Build artifacts
run: |
# Actually do builds and make zips and whatnot
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
echo "dist ran successfully"
- name: Attest
uses: actions/attest-build-provenance@v4
with:
subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*"
- id: cargo-dist
name: Post-build
shell: bash
run: |
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
build-global-artifacts:
needs:
- plan
- build-local-artifacts
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
- name: Fetch local artifacts
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
run: |
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
echo "dist ran successfully"
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
with:
name: artifacts-build-global
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
- name: Fetch artifacts
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
run: |
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
echo "artifacts uploaded and released successfully"
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7
with:
name: artifacts-dist-manifest
path: dist-manifest.json
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: artifacts
merge-multiple: true
- name: Cleanup
run: |
# Remove the granular manifests
rm -f artifacts/*-dist-manifest.json
- name: Create GitHub Release
env:
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
RELEASE_COMMIT: "${{ github.sha }}"
run: |
awk '/^## \[/{c++} c==1' CHANGELOG.md > $RUNNER_TEMP/notes.txt
# Tag-push releases are created as drafts; the `announce` job un-drafts
# them only after the build succeeds, so watcher notifications don't
# fire on broken builds.
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --draft --title "${{ needs.plan.outputs.tag }}" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
publish-homebrew-formula:
needs:
- plan
- host
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAN: ${{ needs.plan.outputs.val }}
GITHUB_USER: "axo bot"
GITHUB_EMAIL: "[email protected]"
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: true
repository: "stalwartlabs/homebrew-tap"
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- name: Fetch homebrew formulae
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: Formula/
merge-multiple: true
- name: Commit formula files
run: |
git config --global user.name "${GITHUB_USER}"
git config --global user.email "${GITHUB_EMAIL}"
for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do
filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output)
name=$(echo "$filename" | sed "s/\.rb$//")
version=$(echo "$release" | jq .app_version --raw-output)
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
brew update
# We avoid reformatting user-provided data such as the app description and homepage.
brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true
git add "Formula/${filename}"
git commit -m "${name} ${version}"
done
git push
publish-npm:
needs:
- plan
- host
runs-on: "ubuntu-22.04"
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
permissions:
"contents": "read"
"id-token": "write"
steps:
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Fetch npm tarball
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: artifacts/
merge-multiple: true
- name: Upgrade npm for trusted publishing
run: npm install -g npm@latest
- name: Publish to npm
env:
IS_PRERELEASE: ${{ fromJson(needs.plan.outputs.val).announcement_is_prerelease }}
run: |
TAG_FLAG=""
if [ "$IS_PRERELEASE" = "true" ]; then
TAG_FLAG="--tag next"
fi
npm publish ./artifacts/vandelay-npm-package.tar.gz --provenance --access public $TAG_FLAG
announce:
needs:
- plan
- host
- publish-homebrew-formula
- publish-npm
if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') && (needs.publish-npm.result == 'skipped' || needs.publish-npm.result == 'success') }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Un-draft release
run: gh release edit "${{ needs.plan.outputs.tag }}" --draft=false --repo "${{ github.repository }}"
cleanup:
name: Cleanup failed release
needs:
- plan
- build-local-artifacts
- build-global-artifacts
- host
- publish-homebrew-formula
- publish-npm
- announce
if: ${{ failure() && needs.plan.outputs.publishing == 'true' }}
runs-on: "ubuntu-22.04"
permissions:
contents: write
steps:
- name: Delete draft release and tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release delete "${{ needs.plan.outputs.tag }}" --yes --cleanup-tag --repo "${{ github.repository }}" || true
+10
View File
@@ -0,0 +1,10 @@
debug
target
**/*.rs.bk
**/mutants.out*/
.env
*.sqlite
*.md
!README.md
!CHANGELOG.md
specs/
+12
View File
@@ -0,0 +1,12 @@
# Change Log
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [1.0.0] - 2026-05-29
### Added
- Initial release.
### Changed
### Fixed
Generated
+3586
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
[package]
name = "vandelay"
description = "JMAP account migration utility"
version = "1.0.0"
authors = ["Stalwart Labs LLC <[email protected]>"]
license = "Apache-2.0 OR MIT"
repository = "https://github.com/stalwartlabs/vandelay"
homepage = "https://github.com/stalwartlabs/vandelay"
edition = "2024"
readme = "README.md"
[package.metadata.wix]
upgrade-guid = "5C8148E7-34A9-4216-A802-FFB8345AECC4"
path-guid = "FA9E97EB-5E5C-4809-9018-9DC1EDB0E93D"
license = false
eula = false
[[bin]]
name = "vandelay"
path = "src/main.rs"
[dependencies]
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0"
indexmap = { version = "2.14.0", features = ["serde"] }
rusqlite = { version = "0.40", features = ["bundled", "blob"] }
blake3 = "1.8.5"
time = { version = "0.3.47", features = ["serde-well-known", "macros"] }
url= "2.5.8"
thiserror = "2.0.18"
ureq = { version = "3.3.0", features = ["json", "rustls-no-provider", "platform-verifier"] }
clap = { version = "4.6.1", features = ["derive"] }
crossbeam-channel = "0.5.15"
num_cpus = "1.17.0"
base64 = "0.22.1"
mail-parser = "0.11.3"
calcard = "0.3"
rustls = { version = "0.23.40", default-features = false, features = ["aws-lc-rs", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
regex = "1.11"
flate2 = "1.1.9"
quick-xml = "0.40"
chrono = { version = "0.4", default-features = false, features = ["std"] }
chrono-tz = { version = "0.10", default-features = false }
[dev-dependencies]
mockito = "1.7.2"
tempfile = "3.13"
testcontainers = { version = "0.27", features = ["blocking"] }
[profile.dist]
inherits = "release"
lto = "thin"
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+23
View File
@@ -0,0 +1,23 @@
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+260
View File
@@ -0,0 +1,260 @@
<h1 align="center">Vandelay</h1>
<h3 align="center">
The JMAP importer-exporter
<br/>
<sub>One-shot account migration and backup across JMAP, IMAP, CalDAV, CardDAV, WebDAV, ManageSieve, Maildir, Google Takeout and Microsoft Exchange.</sub>
</h3>
<br>
<p align="center">
<a href="https://crates.io/crates/vandelay"><img src="https://img.shields.io/crates/v/vandelay?style=flat-square" alt="crates.io"></a>
&nbsp;
<a href="https://github.com/stalwartlabs/vandelay/actions/workflows/rust.yml"><img src="https://img.shields.io/github/actions/workflow/status/stalwartlabs/vandelay/rust.yml?style=flat-square" alt="build"></a>
&nbsp;
<a href="https://docs.rs/vandelay"><img src="https://img.shields.io/docsrs/vandelay?style=flat-square" alt="docs.rs"></a>
&nbsp;
<a href="http://www.apache.org/licenses/LICENSE-2.0"><img src="https://img.shields.io/crates/l/vandelay?style=flat-square" alt="license"></a>
</p>
<br>
<img align="right" src="assets/importer-exporter.jpg" alt="He's an importer-exporter." hspace="20" vspace="6">
- Jerry: Well, what does *he* do?
- George: He's an importer.
- Jerry: Just imports, no exports?
- George: He's an importer/exporter, okay?
<br clear="all">
&nbsp;
## About
Vandelay is a one-shot account-migration utility for JMAP, the JMAP analogue of `imapsync` generalised to every JMAP data type (mail, contacts, calendars, identities, sieve scripts, file storage). It imports an account from a wide range of source protocols into a local SQLite "archive", then exports that archive into a target JMAP server. Import and export never talk to each other, only to the SQLite archive. One archive holds exactly one account.
Because the archive is a self-contained SQLite file that fully describes one account, vandelay doubles as a per-account backup tool: run an import on a schedule to capture a fresh snapshot, keep the resulting SQLite file as your backup, and restore it later by running an export against a JMAP target.
## Features
- **Many source protocols:**
- JMAP
- IMAP
- CalDAV
- CardDAV
- WebDAV
- ManageSieve
- Maildir++
- Google Takeout
- Microsoft Exchange via EWS (*experimental*)
- Microsoft Exchange Online via Graph (*experimental*)
- **One target protocol:** JMAP, with type-by-type stateless re-matching on every run.
- **Convergent:** Re-running an interrupted import or export picks up where it left off without bookkeeping flags.
- **Multi-threaded, no async runtime:** Blocking HTTP with per-server concurrency caps respected automatically.
- **Content-addressed blobs:** Emails, sieve scripts and file-storage payloads are stored once by BLAKE3 hash and deduplicated across the archive.
- **Dry-run everywhere:** Every command supports `--dry-run` to compute the full plan without writing.
- **Source-change protection:** An archive remembers which account it was filled from; pointing it at a different one fails unless explicitly permitted.
- **Read-only inspection:** A built-in `inspect` command dumps any object type from an archive for verification.
## CLI quick reference
```
vandelay <import|export|inspect> [args...]
```
All actions accept a set of global flags (verbosity, worker pool size, retry policy, TLS handling) in addition to action-specific ones. The most useful are:
| Flag | Purpose |
| --- | --- |
| `-j, --threads <N>` | Worker pool size (default: logical CPUs). |
| `--dry-run` | Compute the full plan; perform no writes. |
| `-v`, `-vv`, `-vvv` | Increase log verbosity. |
| `-q, --quiet` | Warnings and errors only. |
| `--max-retries <N>` | Max retries per request on transient failures (default 5). |
| `--allow-invalid-certs` | Accept self-signed / invalid TLS certs. |
Credentials should be supplied via the `VANDELAY_PASSWORD` / `VANDELAY_TOKEN` / `VANDELAY_EWS_CLIENT_SECRET` / `VANDELAY_GRAPH_TOKEN` environment variables, or via an interactive prompt; passing them on the command line is supported but not recommended.
### Import
```
vandelay import <source> [source-args...] <ARCHIVE>
```
Reads a source account into the local SQLite `ARCHIVE` (created if absent). Every importer accepts `--allow-source-change` to override the archive's source-identity guard.
#### JMAP
```
vandelay import jmap \
--url <URL> \
(--auth-basic <USER> [--auth-password <PASS>] | --auth-bearer [TOKEN]) \
(--account-id <ID> | --account-name <NAME>) \
[--objects <list>] \
<ARCHIVE>
```
Imports a single JMAP account. `--objects` accepts a comma-separated list of object tokens (`mailbox,email,calendar,calendarevent,addressbook,contactcard,identity,sievescript,participantidentity,filenode`); default is everything the server advertises.
#### IMAP
```
vandelay import imap \
--url imap(s)://host[:port] \
(--auth-basic <USER> [--auth-password <PASS>] | --auth-bearer [TOKEN] --auth-user <USER>) \
[--include <REGEX>...] [--exclude <REGEX>...] [--exclude-special <ROLE>...] \
[--folder <NAME>...] [--subscribed-only] [--noautomap] \
[--include-deleted] [--allow-cleartext] [--compress] \
[--fetch-batch <N>] [--imap-connections <1..8>] \
<ARCHIVE>
```
Imports mail (and only mail) from any IMAP server. Folder selection is via `--include`/`--exclude` regexes (mutually exclusive with the exact-match `--folder`); `--exclude-special` drops by SPECIAL-USE role.
#### CalDAV
```
vandelay import caldav \
--url <http(s)://host[/path]> \
(--auth-basic <USER> [--auth-password <PASS>] | --auth-bearer [TOKEN]) \
[--allow-cleartext] [--dav-connections <1..8>] [--multiget-batch <N>] \
<ARCHIVE>
```
Discovers the user's CalDAV principal (or accepts a URL pointing straight at a calendar-home or calendar), then imports calendars and events.
#### CardDAV
```
vandelay import carddav \
--url <http(s)://host[/path]> \
(--auth-basic <USER> [--auth-password <PASS>] | --auth-bearer [TOKEN]) \
[--allow-cleartext] [--dav-connections <1..8>] [--multiget-batch <N>] \
<ARCHIVE>
```
Same shape as `caldav`, but for address books and contacts.
#### WebDAV
```
vandelay import webdav \
--url <http(s)://host[/path]> \
(--auth-basic <USER> [--auth-password <PASS>] | --auth-bearer [TOKEN]) \
[--allow-cleartext] [--dav-connections <1..8>] [--multiget-batch <N>] \
<ARCHIVE>
```
Imports a plain WebDAV file collection as a JMAP `FileNode` tree.
#### ManageSieve
```
vandelay import managesieve \
--url sieve(s)://host[:port] \
(--auth-basic <USER> [--auth-password <PASS>] | --auth-bearer [TOKEN] --auth-user <USER>) \
[--allow-cleartext] \
<ARCHIVE>
```
Imports sieve scripts only. Each script is content-addressed in the blob table; the active script is recorded.
#### Maildir
```
vandelay import maildir <MAILDIR> <ARCHIVE> \
[--include <REGEX>...] [--exclude <REGEX>...] [--folder <NAME>...] \
[--noautomap] [--include-deleted]
```
Reads a local Maildir++ tree (a directory with `cur/`, `new/`, `tmp/`). No network. Folder selection mirrors the IMAP importer.
#### Google Takeout
```
vandelay import takeout <PATH> <ARCHIVE> [--noautomap]
```
Scans a directory tree recursively for `.mbox`, `.ics` and `.vcf` files and imports them. Tailored to Google Takeout layouts but works on any such tree; system-label role assignment can be disabled with `--noautomap`.
#### Microsoft Exchange (EWS)
```
vandelay import exchange-ews \
[--url <EWS-ENDPOINT>] [--mailbox <SMTP>] \
[--mailbox-kind primary|archive|public-folders] \
(--auth-basic <USER> [--auth-password <PASS>] \
| --auth-bearer [TOKEN] [--ews-tenant <T> --ews-client-id <ID> \
(--ews-device-code | --ews-client-secret <SECRET>)]) \
[--ews-connections <1..8>] [--ews-getitem-batch <N>] [--ews-attachment-batch <N>] \
[--ews-no-syncfolderitems] \
<ARCHIVE>
```
Imports a mailbox via EWS, against either on-prem Exchange Server or Exchange Online. Autodiscover is used when `--url` is omitted (a `--mailbox` SMTP address is then required). Supports Basic, pre-acquired bearer, interactive device-code OAuth, and app-only client-credentials OAuth.
#### Microsoft Exchange (Graph)
```
vandelay import exchange-graph \
(--client-id <UUID> [--tenant <ID>] | --access-token [TOKEN]) \
[--user <UPN|UUID>] \
[--mailbox-kind primary|archive] \
[--objects mail,calendar,contacts] \
[--event-body-format text|html] \
[--graph-connections <1..16>] [--top <1..1000>] \
<ARCHIVE>
```
Imports a mailbox from Exchange Online via Microsoft Graph. Without `--access-token`, the interactive device-code flow is used. `public-folders` is rejected here (use `exchange-ews` instead).
### Export
```
vandelay export \
--url <URL> \
(--auth-basic <USER> [--auth-password <PASS>] | --auth-bearer [TOKEN]) \
(--account-id <ID> | --account-name <NAME>) \
[--objects <list>] [--prune [--yes]] \
<ARCHIVE>
```
Stateless re-export of `ARCHIVE` into a target JMAP server account. The default behaviour is upsert-only: matched items are updated, unmatched local items are created, but pre-existing target items not covered by the archive are left alone.
`--prune` enables destructive reconciliation: target objects that do not match anything in the archive are deleted. The confirmation prompt can be skipped with `--yes` for automation. Export speaks JMAP only; no other target protocols are currently supported.
### Inspect
```
vandelay inspect <ARCHIVE> [TYPE] [--limit <N>] [--offset <N>]
```
Read-only dump of a local archive. This command never opens a network connection and never writes to the archive.
- Omit `TYPE` for a per-type summary (counts of every object kind plus blob storage stats).
- Pass an object type to dump it: `mailbox`, `email`, `identity`, `sievescript`, `addressbook`, `contactcard`, `calendar`, `calendarevent`, `participantidentity`, `filenode`.
- `mailbox` and `filenode` render as a tree (`--limit`/`--offset` are ignored); all other types use a paginated list and respect `--limit` and `--offset`.
## Testing
```sh
cargo build
cargo clippy --all-targets
cargo test
```
Live integration tests against a Stalwart server and container-based smoke tests against third-party servers (Dovecot, Cyrus, Radicale, Baikal, Apache `mod_dav`) are gated behind `--ignored` and require Docker plus the seeder fixtures.
## License
Licensed under either of
* Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
* MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
## Copyright
Copyright (C) 2020, Stalwart Labs LLC
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+15
View File
@@ -0,0 +1,15 @@
[workspace]
members = ["cargo:."]
[dist]
cargo-dist-version = "0.31.0"
ci = "github"
installers = ["shell", "powershell", "npm", "homebrew", "msi"]
tap = "stalwartlabs/homebrew-tap"
npm-scope = "@stalwartlabs"
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "armv7-unknown-linux-gnueabihf", "armv7-unknown-linux-musleabihf", "arm-unknown-linux-gnueabihf", "arm-unknown-linux-musleabihf", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"]
install-path = "CARGO_HOME"
publish-jobs = ["homebrew", "npm"]
install-updater = false
github-attestations = true
allow-dirty = ["ci"]
+40
View File
@@ -0,0 +1,40 @@
BEGIN:VCALENDAR
METHOD:PUBLISH
X-WR-TIMEZONE:America/Vancouver
PRODID:-//Apple Inc.//iCal 3.0//EN
CALSCALE:GREGORIAN
X-WR-CALNAME:weekly
VERSION:2.0
X-WR-RELCALID:967F55F6-968E-49EF-A050-DAF6DB680D39
X-APPLE-CALENDAR-COLOR:#F57802
BEGIN:VTIMEZONE
TZID:America/Vancouver
BEGIN:DAYLIGHT
TZOFFSETFROM:-0800
TZOFFSETTO:-0700
DTSTART:20070311T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
TZNAME:PDT
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:-0700
TZOFFSETTO:-0800
DTSTART:20071104T020000
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
TZNAME:PST
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
SEQUENCE:7
DESCRIPTION:It will pay the bills.
UID:CFE00EC6-AB4A-4FCE-A32F-6076BA1D8578
TRANSP:OPAQUE
URL;VALUE=URI:http://www.worklessparty.org/
DTSTART;TZID=America/Vancouver:20090309T090000
DTSTAMP:20090310T034303Z
SUMMARY:Go to work.
CREATED:20090310T033824Z
DTEND;TZID=America/Vancouver:20090309T170000
RRULE:FREQ=WEEKLY;INTERVAL=1
END:VEVENT
END:VCALENDAR
+20
View File
@@ -0,0 +1,20 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Apple Inc.//iCal 3.0//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
SEQUENCE:1
DESCRIPTION:Sunday - Partly cloudy with a 20 percent chance of snow show
ers. Highs in the lower to mid 40s.\n<a href=\"http://www.wunderground.c
om/US/WA/Leavenworth.html\">More Information</a>
UID:1205712057-8-1055761864
TRANSP:TRANSPARENT
STATUS:CONFIRMED
DTSTART;VALUE=DATE:20080316
DTSTAMP:20080310T010500Z
SUMMARY:Partly Cloudy 48F / F | 9C / C
CREATED:20080316T213048Z
DTEND;VALUE=DATE:20080317
LOCATION:Leavenworth\, WA
END:VEVENT
END:VCALENDAR
+22
View File
@@ -0,0 +1,22 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Apple Inc.//iCal 3.0//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
SEQUENCE:1
DESCRIPTION:Saturday - Mostly cloudy. A chance of snow showers in the mo
rning...then a chance of rain or snow showers in the afternoon. Highs in
the mid 40s to lower 50s. Chance of precipitation 40 percent. \n<a href
=\"http://www.wunderground.com/US/WA/Leavenworth.html\">More Information
</a>
UID:1205647257-7--150403793
TRANSP:TRANSPARENT
STATUS:CONFIRMED
DTSTART;VALUE=DATE:20080315
DTSTAMP:20080310T010500Z
SUMMARY:Partly Cloudy 48F / 25F | 9C / -4C
CREATED:20080316T213048Z
DTEND;VALUE=DATE:20080316
LOCATION:Leavenworth\, WA
END:VEVENT
END:VCALENDAR
+32
View File
@@ -0,0 +1,32 @@
BEGIN:VCALENDAR
PRODID:-//ABC Corporation/NONSGML iCal4j 1.0//EN
CALSCALE:GREGORIAN
VERSION:2.0
BEGIN:VEVENT
DTSTAMP:20160504T175305Z
DTSTART;TZID=America/New_York:20160514T130000
SUMMARY:Simple event
DURATION:PT1H
UID:20160504T175305Z-uidGen@fe80:0:0:0:c8d8:72ff:fe94:1087%awdl0
LOCATION:Mo's bar - back room
DESCRIPTION:We'll continue with the unfinished business from last time\,\
nin particular:\nCan names start wih a number?\nWhat if they are all num
eric?\nReuse of names - is it valid\nI remind the attendees we have spen
t 3 months on these subjects. We need closure!!!
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20160504T175311Z
DTSTART;TZID=America/New_York:20160514T130000
SUMMARY:Simple Meeting
DURATION:PT1H
UID:20160504T175311Z-uidGen@fe80:0:0:0:c8d8:72ff:fe94:1087%awdl0
ATTENDEE;ROLE=REQ-PARTICIPANT;CN=Person 1:mailto:[email protected]
ATTENDEE;ROLE=OPT-PARTICIPANT;CN=Person 2:mailto:[email protected]
ORGANIZER;CN=Person 1:mailto:[email protected]
LOCATION:Mo's bar - back room
DESCRIPTION:We'll continue with the unfinished business from last time\,\
nin particular:\nCan names start wih a number?\nWhat if they are all num
eric?\nReuse of names - is it valid\nI remind the attendees we have spen
t 3 months on these subjects. We need closure!!!
END:VEVENT
END:VCALENDAR
+44
View File
@@ -0,0 +1,44 @@
BEGIN:VCALENDAR
METHOD:PUBLISH
PRODID:Microsoft Exchange Server 2010
VERSION:2.0
X-WR-CALNAME:Calendar
BEGIN:VTIMEZONE
TZID:Customized Time Zone
BEGIN:STANDARD
DTSTART:16010101T020000
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
DESCRIPTION:[private]
RRULE:FREQ=WEEKLY;UNTIL=20201217T153500Z;INTERVAL=1;BYDAY=TU,TH;WKST=SU
UID:1173422081SZR322
SUMMARY:[private]
DTSTART;TZID=Customized Time Zone:20200825T103500
DTEND;TZID=Customized Time Zone:20200825T115000
CLASS:PUBLIC
PRIORITY:5
DTSTAMP:20200901T175645Z
TRANSP:OPAQUE
STATUS:CONFIRMED
SEQUENCE:0
LOCATION: [private]
X-MICROSOFT-CDO-APPT-SEQUENCE:0
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-INSTTYPE:1
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
X-MICROSOFT-DISALLOW-COUNTER:FALSE
END:VEVENT
END:VCALENDAR
+40
View File
@@ -0,0 +1,40 @@
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:ical
X-WR-TIMEZONE:US/Central
X-WR-CALDESC:
BEGIN:VEVENT
UID:98765432-ABCD-DCBB-999A-987765432123
DTSTART;TZID=US/Central:20170601T090000
DTEND;TZID=US/Central:20170601T170000
DTSTAMP:20170727T044436Z
EXDATE;TZID=US/Central:20170706T090000,20170713T090000,20170720T090000,20
170803T090000
LAST-MODIFIED:20170727T044435Z
RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20170814T045959Z;BYDAY=TH
SEQUENCE:0
SUMMARY:Recurring weekly meeting from June 1 - Aug 14 (except July 6, July 13, July 20, Aug 3)
END:VEVENT
BEGIN:VEVENT
UID:98765432-ABCD-DCBB-999A-987765432123
RECURRENCE-ID;TZID=US/Central:20170629T090000
DTSTART;TZID=US/Central:20170703T090000
DTEND;TZID=US/Central:20170703T120000
DTSTAMP:20170727T044436Z
LAST-MODIFIED:20170216T143445Z
SEQUENCE:0
SUMMARY:Last meeting in June moved to Monday July 3 and shortened to half day
END:VEVENT
BEGIN:VEVENT
UID:12354454-ABCD-DCBB-999A-2349872354897
DTSTART;TZID=US/Central:20171201T130000
DTEND;TZID=US/Central:20171201T150000
DTSTAMP:20170727T044436Z
LAST-MODIFIED:20170727T044435Z
SEQUENCE:0
SUMMARY:Single event on Dec 1
END:VEVENT
END:VCALENDAR
+15
View File
@@ -0,0 +1,15 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART;TZID=Europe/Germany:20241022T230000
DTEND;TZID=TZID=Europe/Germany:20241023T000000
RRULE:FREQ=DAILY;WKST=MO;COUNT=4
DTSTAMP:20241009T153220Z
UID:[email protected]
CREATED:20241009T153058Z
LAST-MODIFIED:20241009T153205Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:TestCal_AllDayRepeatingEvent
TRANSP:TRANSPARENT
END:VEVENT
END:VCALENDAR
+78
View File
@@ -0,0 +1,78 @@
BEGIN:VCALENDAR
PRODID:-//lanyrd.com//Lanyrd//EN
X-ORIGINAL-URL:http://lanyrd.com/topics/nodejs/nodejs.ics
X-WR-CALNAME;CHARSET=utf-8:Node.js conferences
VERSION:2.0
METHOD:PUBLISH
BEGIN:VEVENT
SUMMARY;CHARSET=utf-8:Dyncon 2011
LOCATION;CHARSET=utf-8:Stockholm, Sweden
URL:http://lanyrd.com/2011/dyncon/
UID:d4c826dfb701f611416d69b4df81caf9ff80b03a
DTSTART:20110312T200000Z
DTEND;VALUE=DATE:20110314
END:VEVENT
BEGIN:VEVENT
SUMMARY;CHARSET=utf-8:[Async]: Everything Express
LOCATION;CHARSET=utf-8:Brighton, United Kingdom
URL:http://lanyrd.com/2011/asyncjs-express/
UID:480a3ad48af5ed8965241f14920f90524f533c18
DTSTART;VALUE=DATE:20110324
DTEND;VALUE=DATE:20110325
END:VEVENT
BEGIN:VEVENT
SUMMARY;CHARSET=utf-8:JSConf US 2011
LOCATION;CHARSET=utf-8:Portland, United States
URL:http://lanyrd.com/2011/jsconf/
UID:ed334cc85db5ebdff5ff5a630a7a48631a677dbe
DTSTART;VALUE=DATE:20110502
DTEND;VALUE=DATE:20110504
END:VEVENT
BEGIN:VEVENT
SUMMARY;CHARSET=utf-8:NodeConf 2011
LOCATION;CHARSET=utf-8:Portland, United States
URL:http://lanyrd.com/2011/nodeconf/
UID:25169a7b1ba5c248278f47120a40878055dc8c15
DTSTART;VALUE=DATE:20110505
DTEND;VALUE=DATE:20110506
END:VEVENT
BEGIN:VEVENT
SUMMARY;CHARSET=utf-8:BrazilJS
LOCATION;CHARSET=utf-8:Fortaleza, Brazil
URL:http://lanyrd.com/2011/braziljs/
UID:dafee3be83624f3388c5635662229ff11766bb9c
DTSTART;VALUE=DATE:20110513
DTEND;VALUE=DATE:20110515
END:VEVENT
BEGIN:VEVENT
SUMMARY;CHARSET=utf-8:Falsy Values
LOCATION;CHARSET=utf-8:Warsaw, Poland
URL:http://lanyrd.com/2011/falsy-values/
UID:73cad6a09ac4e7310979c6130f871d17d990b5ad
DTSTART;VALUE=DATE:20110518
DTEND;VALUE=DATE:20110521
END:VEVENT
BEGIN:VEVENT
SUMMARY;CHARSET=utf-8:nodecamp.eu
LOCATION;CHARSET=utf-8:Cologne, Germany
URL:http://lanyrd.com/2011/nodecampde/
UID:b728a5fdb5f292b6293e4a2fd97a1ccfc69e9d6f
DTSTART;VALUE=DATE:20110611
DTEND;VALUE=DATE:20110613
END:VEVENT
BEGIN:VEVENT
SUMMARY;CHARSET=utf-8:Rich Web Experience 2011
LOCATION;CHARSET=utf-8:Fort Lauderdale, United States
URL:http://lanyrd.com/2011/rich-web-experience/
UID:47f6ea3f28af2986a2192fa39a91fa7d60d26b76
DTSTART;VALUE=DATE:20111129
DTEND;VALUE=DATE:20111203
END:VEVENT
BEGIN:VEVENT
SUMMARY;CHARSET=utf-8:Foobar
UID:sdfkf09fsd0
DTSTART;VALUE=DATE:Next Year
DTEND;VALUE=DATE:20111203
END:VEVENT
END:VCALENDAR
+16
View File
@@ -0,0 +1,16 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
UID:eb9e1bd2-ceba-499f-be77-f02773954c72
SUMMARY:Event with an alarm
DESCRIPTION:This is an event with an alarm.
ORGANIZER;[email protected]:"mailto:[email protected]"
DTSTART;TZID="America/Los_Angeles":20130418T110000
DTEND;TZID="America/Los_Angeles":20130418T120000
STATUS:CONFIRMED
CLASS:PUBLIC
TRANSP:OPAQUE
LAST-MODIFIED:20130418T175632Z
DTSTAMP:20130418T175632Z
SEQUENCE:3
END:VEVENT
END:VCALENDAR
+75
View File
@@ -0,0 +1,75 @@
BEGIN:VCALENDAR
METHOD:PUBLISH
VERSION:2.0
X-WR-CALNAME:Test
PRODID:-//Apple Inc.//macOS 11.6.7//EN
X-WR-TIMEZONE:America/Vancouver
CALSCALE:GREGORIAN
BEGIN:VEVENT
TRANSP:OPAQUE
DTEND:20220704T235000Z
X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC
ORGANIZER;SCHEDULE-AGENT=CLIENT:mailto:[email protected]
UID:31a1ffc9-9b76-465b-ae4a-cadb694c9d37
DTSTAMP:20220704T225759Z
DESCRIPTION:
STATUS:TENTATIVE
SEQUENCE:0
SUMMARY:Tentative
LAST-MODIFIED:20220704T230434Z
DTSTART:20220704T230000Z
CREATED:20220704T225437Z
ATTENDEE;CN="[email protected]";CUTYPE=INDIVIDUAL;EMAIL="tentative@e
xample.com";PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailt
o:[email protected]
END:VEVENT
BEGIN:VEVENT
TRANSP:OPAQUE
DTEND;TZID=America/Vancouver:20220705T100000
X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC
ORGANIZER;SCHEDULE-AGENT=CLIENT:mailto:[email protected]
UID:F00F3710-BF4D-46D3-9A2C-1037AB24C6AC
DTSTAMP:20220704T231538Z
DESCRIPTION:
STATUS:CONFIRMED
SEQUENCE:0
SUMMARY:Confirmed
LAST-MODIFIED:20220704T231538Z
DTSTART;TZID=America/Vancouver:20220705T090000
CREATED:20220704T231522Z
ATTENDEE;CN="[email protected]";CUTYPE=INDIVIDUAL;EMAIL="confirmed@e
xample.com";PARTSTAT=CONFIRMED;ROLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailt
o:[email protected]
END:VEVENT
BEGIN:VEVENT
TRANSP:OPAQUE
DTEND;TZID=America/Vancouver:20220706T140000
X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC
ORGANIZER;SCHEDULE-AGENT=CLIENT:mailto:[email protected]
UID:8CAB46C5-669F-4249-B1AD-52BFA72F4E0A
DTSTAMP:20220704T231547Z
DESCRIPTION:
STATUS:CANCELLED
SEQUENCE:0
SUMMARY:Cancelled
LAST-MODIFIED:20220704T231559Z
DTSTART;TZID=America/Vancouver:20220706T130000
CREATED:20220704T231539Z
ATTENDEE;CN="[email protected]";CUTYPE=INDIVIDUAL;EMAIL="cancelled@e
xample.com";PARTSTAT=cancelled;ROLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailt
o:[email protected]
END:VEVENT
BEGIN:VEVENT
TRANSP:OPAQUE
DTEND;TZID=America/Vancouver:20220707T100000
X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC
UID:99F615DE-82C6-4CEF-97B8-CD0D3E1EE0D3
DTSTAMP:20220704T234828Z
DESCRIPTION:
SEQUENCE:0
SUMMARY:No Status
LAST-MODIFIED:20220704T234828Z
DTSTART;TZID=America/Vancouver:20220707T090000
CREATED:20220704T234823Z
END:VEVENT
END:VCALENDAR
+66
View File
@@ -0,0 +1,66 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
CREATED:20231214T150120Z
LAST-MODIFIED:20231214T150224Z
DTSTAMP:20231214T150224Z
UID:df21da35-3a0b-434a-9211-da9f9095fb16
SUMMARY:test export import
CATEGORIES:Professionnel
DTSTART;TZID=Europe/Paris:20231215T150000
DTEND;TZID=Europe/Paris:20231215T160000
TRANSP:OPAQUE
LOCATION:dijon
DESCRIPTION;ALTREP="data:text/html,test":test
END:VEVENT
BEGIN:VTIMEZONE
TZID: 1
BEGIN:STANDARD
DTSTART:16010101T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20240229T160626Z
LAST-MODIFIED:20231221T141000Z
DTSTAMP:20231221T141000Z
UID:040000008200E00074C5B7101A82E008000000008D1F7DAB8632DA01000000000000000
0100000008EF89A92C90D1342BF1F2CB714276CCC
SUMMARY:Atelier groupes portail/Nuage
PRIORITY:5
STATUS:CONFIRMED
DTSTART;TZID= 1:20240108T110000
DTEND;TZID= 1:20240108T120000
CLASS:PUBLIC
DESCRIPTION;LANGUAGE=fr-FR:Désolé pour ces changements de date\, j'espèr
e que ce dernier créneau conviendra à tout le monde. \n\nJe vous propose
cette date pour un atelier sur les soucis avec les commandes à passer sur
les serveurs nextcloud pour la mise en place des groupes. Dites moi si ça
ne convient pas. Merci !\n
TRANSP:OPAQUE
SEQUENCE:3
LOCATION;LANGUAGE=fr-FR:https://visio-agents.education.fr/
X-MICROSOFT-CDO-APPT-SEQUENCE:3
X-MICROSOFT-CDO-OWNERAPPTID:2122095757
X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-INSTTYPE:0
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
X-MICROSOFT-DISALLOW-COUNTER:FALSE
X-MICROSOFT-LOCATIONDISPLAYNAME:https://visio-agents.education.fr/
X-MICROSOFT-LOCATIONSOURCE:None
X-MICROSOFT-LATITUDE:0
X-MICROSOFT-LONGITUDE:0
X-MOZ-RECEIVED-SEQUENCE:3
X-MOZ-RECEIVED-DTSTAMP:20231221T141000Z
END:VEVENT
END:VCALENDAR
+235
View File
@@ -0,0 +1,235 @@
BEGIN:VCALENDAR
PRODID:-//Offline Calendar//iCal Import/Export 2.8.1//EN
VERSION:2.0
METHOD:PUBLISH
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/London
TZURL:http://tzurl.org/zoneinfo/Europe/London
X-LIC-LOCATION:Europe/London
BEGIN:DAYLIGHT
TZOFFSETFROM:+0000
TZOFFSETTO:+0100
TZNAME:BST
DTSTART:19810329T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0100
TZOFFSETTO:+0000
TZNAME:GMT
DTSTART:19961027T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
BEGIN:STANDARD
TZOFFSETFROM:-000115
TZOFFSETTO:+0000
TZNAME:GMT
DTSTART:18471201T000115
RDATE:18471201T000115
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETFROM:+0000
TZOFFSETTO:+0100
TZNAME:BST
DTSTART:19160521T020000
RDATE:19160521T020000
RDATE:19170408T020000
RDATE:19180324T020000
RDATE:19190330T020000
RDATE:19200328T020000
RDATE:19210403T020000
RDATE:19220326T020000
RDATE:19230422T020000
RDATE:19240413T020000
RDATE:19250419T020000
RDATE:19260418T020000
RDATE:19270410T020000
RDATE:19280422T020000
RDATE:19290421T020000
RDATE:19300413T020000
RDATE:19310419T020000
RDATE:19320417T020000
RDATE:19330409T020000
RDATE:19340422T020000
RDATE:19350414T020000
RDATE:19360419T020000
RDATE:19370418T020000
RDATE:19380410T020000
RDATE:19390416T020000
RDATE:19400225T020000
RDATE:19460414T020000
RDATE:19470316T020000
RDATE:19480314T020000
RDATE:19490403T020000
RDATE:19500416T020000
RDATE:19510415T020000
RDATE:19520420T020000
RDATE:19530419T020000
RDATE:19540411T020000
RDATE:19550417T020000
RDATE:19560422T020000
RDATE:19570414T020000
RDATE:19580420T020000
RDATE:19590419T020000
RDATE:19600410T020000
RDATE:19610326T020000
RDATE:19620325T020000
RDATE:19630331T020000
RDATE:19640322T020000
RDATE:19650321T020000
RDATE:19660320T020000
RDATE:19670319T020000
RDATE:19680218T020000
RDATE:19720319T020000
RDATE:19730318T020000
RDATE:19740317T020000
RDATE:19750316T020000
RDATE:19760321T020000
RDATE:19770320T020000
RDATE:19780319T020000
RDATE:19790318T020000
RDATE:19800316T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0100
TZOFFSETTO:+0000
TZNAME:GMT
DTSTART:19161001T030000
RDATE:19161001T030000
RDATE:19170917T030000
RDATE:19180930T030000
RDATE:19190929T030000
RDATE:19201025T030000
RDATE:19211003T030000
RDATE:19221008T030000
RDATE:19230916T030000
RDATE:19240921T030000
RDATE:19251004T030000
RDATE:19261003T030000
RDATE:19271002T030000
RDATE:19281007T030000
RDATE:19291006T030000
RDATE:19301005T030000
RDATE:19311004T030000
RDATE:19321002T030000
RDATE:19331008T030000
RDATE:19341007T030000
RDATE:19351006T030000
RDATE:19361004T030000
RDATE:19371003T030000
RDATE:19381002T030000
RDATE:19391119T030000
RDATE:19451007T030000
RDATE:19461006T030000
RDATE:19471102T030000
RDATE:19481031T030000
RDATE:19491030T030000
RDATE:19501022T030000
RDATE:19511021T030000
RDATE:19521026T030000
RDATE:19531004T030000
RDATE:19541003T030000
RDATE:19551002T030000
RDATE:19561007T030000
RDATE:19571006T030000
RDATE:19581005T030000
RDATE:19591004T030000
RDATE:19601002T030000
RDATE:19611029T030000
RDATE:19621028T030000
RDATE:19631027T030000
RDATE:19641025T030000
RDATE:19651024T030000
RDATE:19661023T030000
RDATE:19671029T030000
RDATE:19711031T030000
RDATE:19721029T030000
RDATE:19731028T030000
RDATE:19741027T030000
RDATE:19751026T030000
RDATE:19761024T030000
RDATE:19771023T030000
RDATE:19781029T030000
RDATE:19791028T030000
RDATE:19801026T030000
RDATE:19811025T020000
RDATE:19821024T020000
RDATE:19831023T020000
RDATE:19841028T020000
RDATE:19851027T020000
RDATE:19861026T020000
RDATE:19871025T020000
RDATE:19881023T020000
RDATE:19891029T020000
RDATE:19901028T020000
RDATE:19911027T020000
RDATE:19921025T020000
RDATE:19931024T020000
RDATE:19941023T020000
RDATE:19951022T020000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:BDST
DTSTART:19410504T010000
RDATE:19410504T010000
RDATE:19420405T010000
RDATE:19430404T010000
RDATE:19440402T010000
RDATE:19450402T010000
RDATE:19470413T010000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:BST
DTSTART:19410810T030000
RDATE:19410810T030000
RDATE:19420809T030000
RDATE:19430815T030000
RDATE:19440917T030000
RDATE:19450715T030000
RDATE:19470810T030000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0100
TZOFFSETTO:+0100
TZNAME:BST
DTSTART:19681026T230000
RDATE:19681026T230000
END:STANDARD
BEGIN:STANDARD
TZOFFSETFROM:+0000
TZOFFSETTO:+0000
TZNAME:GMT
DTSTART:19960101T000000
RDATE:19960101T000000
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20241005T113036Z
UID:17281276213728ad54d03afa44d1ca60b8c52afaece9e@sufficientlysecure.org
SUMMARY:event with alarms android
STATUS:CONFIRMED
DTSTART;TZID=Europe/London:20241005T130000
DTEND:20241005T130000Z
LAST-MODIFIED:20241005T112701Z
BEGIN:VALARM
TRIGGER:-PT30M
ACTION:DISPLAY
DESCRIPTION:event with alarms android
END:VALARM
BEGIN:VALARM
TRIGGER:-PT25M
ACTION:DISPLAY
DESCRIPTION:event with alarms android
END:VALARM
BEGIN:VALARM
TRIGGER:-PT5M
ACTION:DISPLAY
DESCRIPTION:event with alarms android
END:VALARM
END:VEVENT
END:VCALENDAR
+626
View File
@@ -0,0 +1,626 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/London
X-TZINFO:Europe/London[2024a]
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:-000115
TZNAME:Europe/London(STD)
DTSTART:18471201T000000
RDATE:18471201T000000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19160521T020000
RDATE:19160521T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19161001T030000
RDATE:19161001T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19170408T020000
RDATE:19170408T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19170917T030000
RDATE:19170917T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19180324T020000
RDATE:19180324T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19180930T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1MO;UNTIL=19190929T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19190330T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19200328T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19201025T030000
RDATE:19201025T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19210403T020000
RDATE:19210403T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19211003T030000
RDATE:19211003T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19220326T020000
RDATE:19220326T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19221008T030000
RDATE:19221008T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19230422T020000
RDATE:19230422T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19240413T020000
RDATE:19240413T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19230916T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=3SU;UNTIL=19240921T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19250419T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3SU;UNTIL=19260418T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19270410T020000
RDATE:19270410T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19280422T020000
RDATE:19280422T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19290421T020000
RDATE:19290421T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19300413T020000
RDATE:19300413T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19310419T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3SU;UNTIL=19320417T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19251004T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19321002T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19330409T020000
RDATE:19330409T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19331008T030000
RDATE:19331008T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19340422T020000
RDATE:19340422T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19350414T020000
RDATE:19350414T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19360419T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3SU;UNTIL=19370418T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19380410T020000
RDATE:19380410T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19341007T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19381002T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19390416T020000
RDATE:19390416T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19400225T020000
RDATE:19400225T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19410504T020000
RDATE:19410504T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19410810T030000
RDATE:19410810T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19420405T020000
RDATE:19420405T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19420809T030000
RDATE:19420809T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19430404T020000
RDATE:19430404T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19430815T030000
RDATE:19430815T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19440402T020000
RDATE:19440402T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19440917T030000
RDATE:19440917T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19450402T020000
RDATE:19450402T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19391119T030000
RDATE:19391119T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19450715T030000
RDATE:19450715T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19460414T020000
RDATE:19460414T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19470316T020000
RDATE:19470316T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19470413T020000
RDATE:19470413T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19451007T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19461006T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19470810T030000
RDATE:19470810T030000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19471102T030000
RDATE:19471102T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19480314T020000
RDATE:19480314T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19490403T020000
RDATE:19490403T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19481031T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19491030T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19501022T030000
RDATE:19501022T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19511021T030000
RDATE:19511021T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19521026T030000
RDATE:19521026T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19500416T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3SU;UNTIL=19530419T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19540411T020000
RDATE:19540411T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19550417T020000
RDATE:19550417T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19560422T020000
RDATE:19560422T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19570414T020000
RDATE:19570414T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19580420T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3SU;UNTIL=19590419T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19600410T020000
RDATE:19600410T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19531004T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19601002T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19610326T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19630331T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19640322T020000
RDATE:19640322T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19611029T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19641025T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19651024T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=4SU;UNTIL=19661023T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19650321T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=3SU;UNTIL=19670319T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19671029T030000
RDATE:19671029T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+010000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19681027T000000
RDATE:19681027T000000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19680218T020000
RDATE:19680218T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19711031T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19751026T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19761024T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=4SU;UNTIL=19771023T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19720319T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=3SU;UNTIL=19800316T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19781029T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19801026T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19811025T020000
RDATE:19811025T020000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19821024T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=4SU;UNTIL=19831023T020000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19841028T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19871025T020000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19881023T020000
RDATE:19881023T020000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19891029T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19921025T020000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19931024T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=4SU;UNTIL=19951022T020000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19810329T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19960331T010000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19961027T020000
RDATE:19961027T020000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:(DST)
DTSTART:19970330T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:(STD)
DTSTART:19971026T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20241023T173412Z
LAST-MODIFIED:20241023T173630Z
DTSTAMP:20241023T173630Z
UID:731b9b91-cf72-499b-bbc9-c53c28e21fc7
SUMMARY:event
X-MOZ-LASTACK:20241023T173630Z
DTSTART;TZID=Europe/London:20241023T190000
DTEND;TZID=Europe/London:20241023T200000
TRANSP:OPAQUE
X-MOZ-GENERATION:3
X-MOZ-SNOOZE-TIME:20241023T174130Z
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-PT1M
DESCRIPTION:Mozilla Standardbeschreibung
END:VALARM
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-PT24M
DESCRIPTION:Mozilla Standardbeschreibung
END:VALARM
END:VEVENT
END:VCALENDAR
+625
View File
@@ -0,0 +1,625 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/London
X-TZINFO:Europe/London[2024a]
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:-000115
TZNAME:Europe/London(STD)
DTSTART:18471201T000000
RDATE:18471201T000000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19160521T020000
RDATE:19160521T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19161001T030000
RDATE:19161001T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19170408T020000
RDATE:19170408T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19170917T030000
RDATE:19170917T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19180324T020000
RDATE:19180324T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19180930T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1MO;UNTIL=19190929T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19190330T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19200328T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19201025T030000
RDATE:19201025T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19210403T020000
RDATE:19210403T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19211003T030000
RDATE:19211003T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19220326T020000
RDATE:19220326T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19221008T030000
RDATE:19221008T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19230422T020000
RDATE:19230422T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19240413T020000
RDATE:19240413T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19230916T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=3SU;UNTIL=19240921T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19250419T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3SU;UNTIL=19260418T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19270410T020000
RDATE:19270410T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19280422T020000
RDATE:19280422T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19290421T020000
RDATE:19290421T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19300413T020000
RDATE:19300413T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19310419T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3SU;UNTIL=19320417T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19251004T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19321002T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19330409T020000
RDATE:19330409T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19331008T030000
RDATE:19331008T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19340422T020000
RDATE:19340422T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19350414T020000
RDATE:19350414T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19360419T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3SU;UNTIL=19370418T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19380410T020000
RDATE:19380410T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19341007T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19381002T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19390416T020000
RDATE:19390416T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19400225T020000
RDATE:19400225T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19410504T020000
RDATE:19410504T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19410810T030000
RDATE:19410810T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19420405T020000
RDATE:19420405T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19420809T030000
RDATE:19420809T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19430404T020000
RDATE:19430404T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19430815T030000
RDATE:19430815T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19440402T020000
RDATE:19440402T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19440917T030000
RDATE:19440917T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19450402T020000
RDATE:19450402T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19391119T030000
RDATE:19391119T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19450715T030000
RDATE:19450715T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19460414T020000
RDATE:19460414T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19470316T020000
RDATE:19470316T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+020000
TZOFFSETFROM:+010000
TZNAME:Europe/London(DST)
DTSTART:19470413T020000
RDATE:19470413T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19451007T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19461006T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+020000
TZNAME:Europe/London(DST)
DTSTART:19470810T030000
RDATE:19470810T030000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19471102T030000
RDATE:19471102T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19480314T020000
RDATE:19480314T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19490403T020000
RDATE:19490403T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19481031T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19491030T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19501022T030000
RDATE:19501022T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19511021T030000
RDATE:19511021T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19521026T030000
RDATE:19521026T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19500416T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3SU;UNTIL=19530419T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19540411T020000
RDATE:19540411T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19550417T020000
RDATE:19550417T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19560422T020000
RDATE:19560422T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19570414T020000
RDATE:19570414T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19580420T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3SU;UNTIL=19590419T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19600410T020000
RDATE:19600410T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19531004T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19601002T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19610326T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19630331T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19640322T020000
RDATE:19640322T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19611029T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19641025T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19651024T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=4SU;UNTIL=19661023T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19650321T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=3SU;UNTIL=19670319T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19671029T030000
RDATE:19671029T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+010000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19681027T000000
RDATE:19681027T000000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19680218T020000
RDATE:19680218T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19711031T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19751026T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19761024T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=4SU;UNTIL=19771023T030000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19720319T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=3SU;UNTIL=19800316T020000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19781029T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19801026T030000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19811025T020000
RDATE:19811025T020000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19821024T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=4SU;UNTIL=19831023T020000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19841028T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19871025T020000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19881023T020000
RDATE:19881023T020000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19891029T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19921025T020000
END:STANDARD
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19931024T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=4SU;UNTIL=19951022T020000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:Europe/London(DST)
DTSTART:19810329T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19960331T010000
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:Europe/London(STD)
DTSTART:19961027T020000
RDATE:19961027T020000
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETTO:+010000
TZOFFSETFROM:+000000
TZNAME:(DST)
DTSTART:19970330T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETTO:+000000
TZOFFSETFROM:+010000
TZNAME:(STD)
DTSTART:19971026T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20241023T131035Z
LAST-MODIFIED:20241023T141941Z
DTSTAMP:20241023T141941Z
UID:b9a23b47-f109-4e7a-908c-75e925b27def
SUMMARY:event with alarms
X-MOZ-LASTACK:20241023T141941Z
DTSTART;TZID=Europe/London:20241023T150000
DTEND;TZID=Europe/London:20241023T160000
TRANSP:OPAQUE
X-MOZ-GENERATION:6
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-PT15M
DESCRIPTION:Mozilla Standardbeschreibung
END:VALARM
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-PT45M
DESCRIPTION:Mozilla Standardbeschreibung
END:VALARM
END:VEVENT
END:VCALENDAR
+55
View File
@@ -0,0 +1,55 @@
BEGIN:VCALENDAR
X-SOURCE-URL:https://github.com/pimutils/khal/issues/152#issuecomment-933635248
VERSION:2.0
PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN
BEGIN:VTIMEZONE
TZID:Western/Central Europe
BEGIN:STANDARD
DTSTART:19501029T020000
RRULE:FREQ=YEARLY;BYMINUTE=0;BYHOUR=2;BYDAY=-1SU;BYMONTH=10
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19500326T020000
RRULE:FREQ=YEARLY;BYMINUTE=0;BYHOUR=2;BYDAY=-1SU;BYMONTH=3
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
SUMMARY:(omitted)
DTSTART;TZID="Western/Central Europe";VALUE=DATE-TIME:20211101T160000
DTEND;TZID="Western/Central Europe";VALUE=DATE-TIME:20211101T163000
DTSTAMP:20211004T150245Z
UID:BF5109494E67AAE20025875100566D31-Lotus_Notes_Generated
RECURRENCE-ID;RANGE=THISANDFUTURE:20211101T150000Z
SEQUENCE:0
RDATE;TZID="Western/Central Europe";VALUE=PERIOD:20211101T160000/20211101T
163000,20211206T160000/20211206T163000,20220103T160000/20220103T163000,202
20207T160000/20220207T163000
ATTENDEE;CN="(omitted)";PARTSTAT=ACCEPTED;ROLE=CHAIR;RSVP=FAL
SE:mailto:[email protected]
CLASS:PUBLIC
TRANSP:OPAQUE
X-LOTUS-APPTTYPE:3
X-LOTUS-AUDIOVIDEOFLAGS:0
X-LOTUS-BROADCAST:FALSE
X-LOTUS-CHANGE-INST-DATES:20211101T150000Z\,20211206T150000Z\,20220103T150
000Z\,20220207T150000Z
X-LOTUS-CHILD-UID:567EFBAF6CBD07FC0025875100566D3B
X-LOTUS-INITIAL-RDATES:20211101T150000Z\,20211206T150000Z\,20220103T150000
Z\,20220207T150000Z
X-LOTUS-LASTALL-RDATES;TZID="Western/Central Europe":20211101T160000\,2021
1206T160000\,20220103T160000\,20220207T160000
X-LOTUS-NOTESVERSION:2
X-LOTUS-NOTICETYPE:I
X-LOTUS-RECURID;RANGE=THISANDFUTURE:20211101T150000Z
X-LOTUS-UPDATE-SEQ:2
X-LOTUS-UPDATE-WISL:$W:1\;$O:1\;$M:1\;RequiredAttendees:1\;INetRequiredNam
es:1\;AltRequiredNames:1\;StorageRequiredNames:1\;OptionalAttendees:1\;INe
tOptionalNames:1\;AltOptionalNames:1\;StorageOptionalNames:1\;ApptUNIDURL:
1\;STUnyteConferenceURL:1\;STUnyteConferenceID:1\;SametimeType:1\;WhiteBoa
rdContent:1\;STRoomName:1\;$S:2\;$B:2\;$L:2\;$E:2\;$R:2
END:VEVENT
END:VCALENDAR
+436
View File
@@ -0,0 +1,436 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Last.fm Limited Event Feeds//NONSGML//EN
X-WR-CALNAME:Last.fm Events
X-WR-CALDESC:Event listing - supplied by http://www.Last.fm
BEGIN:VEVENT
UID:LFMEVENT-1407206
URL;VALUE=URI:http://www.last.fm/event/1407206+Gerard+van+Maasakkers+en+de+vaste+mannen+-+Deze+Jongen
DTSTAMP:20100210T203000Z
DTSTART:20100210T203000
DTEND:20100210T235900
SUMMARY:Gerard van Maasakkers en de vaste mannen - Deze Jongen
DESCRIPTION:Gerard van Maasakkers
\n\nhttp://www.last.fm/event/1407206+Gerard+van+Maasakkers+en+de+vaste+mannen+-+Deze+Jongen
LOCATION:Deventer Schouwburg\, Netherlands
GEO:6.161613;52.255862
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1361665
URL;VALUE=URI:http://www.last.fm/event/1361665+Millennium+Jazz+Orchestra+en+Frank+Marocco
DTSTAMP:20100212T200000Z
DTSTART:20100212T200000
DTEND:20100212T235900
SUMMARY:Millennium Jazz Orchestra en Frank Marocco
DESCRIPTION:Millennium Jazz Orchestra\, Frank Marocco
\n\nhttp://www.last.fm/event/1361665+Millennium+Jazz+Orchestra+en+Frank+Marocco
LOCATION:Deventer Schouwburg\, Netherlands
GEO:6.161613;52.255862
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1358028
URL;VALUE=URI:http://www.last.fm/event/1358028+Zu+at+Burgerweeshuis+on+12+February+2010
DTSTAMP:20100212T210000Z
DTSTART:20100212T210000
DTEND:20100212T235900
SUMMARY:Zu + The New Earth Group at Burgerweeshuis
DESCRIPTION:Zu\, The New Earth Group
\n\nhttp://www.last.fm/event/1358028+Zu+at+Burgerweeshuis+on+12+February+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1319333
URL;VALUE=URI:http://www.last.fm/event/1319333+Fink+at+Burgerweeshuis+on+13+February+2010
DTSTAMP:20100213T210000Z
DTSTART:20100213T210000
DTEND:20100213T235900
SUMMARY:Fink at Burgerweeshuis
DESCRIPTION:Fink
\n\nhttp://www.last.fm/event/1319333+Fink+at+Burgerweeshuis+on+13+February+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1315921
URL;VALUE=URI:http://www.last.fm/event/1315921+Love+For+Lunch
DTSTAMP:20100219T093200Z
DTSTART:20100219T000000
DTEND:20100219T235900
SUMMARY:Love For Lunch
DESCRIPTION:Gare du Nord
\n\nhttp://www.last.fm/event/1315921+Love+For+Lunch
LOCATION:Deventer Schouwburg\, Netherlands
GEO:6.161613;52.255862
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1361649
URL;VALUE=URI:http://www.last.fm/event/1361649+Amsterdam+Klezmer+Band+at+Theater+Bouwkunde+on+19+February+2010
DTSTAMP:20100219T203000Z
DTSTART:20100219T203000
DTEND:20100219T235900
SUMMARY:Amsterdam Klezmer Band at Theater Bouwkunde
DESCRIPTION:Amsterdam Klezmer Band
\n\nhttp://www.last.fm/event/1361649+Amsterdam+Klezmer+Band+at+Theater+Bouwkunde+on+19+February+2010
LOCATION:Theater Bouwkunde\, Netherlands
GEO:6.153074;52.253485
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1334619
URL;VALUE=URI:http://www.last.fm/event/1334619+Bettie+Serveert+at+Burgerweeshuis+on+19+February+2010
DTSTAMP:20100219T210000Z
DTSTART:20100219T210000
DTEND:20100219T235900
SUMMARY:Bettie Serveert + The Monroes at Burgerweeshuis
DESCRIPTION:Bettie Serveert\, The Monroes
\n\nhttp://www.last.fm/event/1334619+Bettie+Serveert+at+Burgerweeshuis+on+19+February+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1358751
URL;VALUE=URI:http://www.last.fm/event/1358751+Ethisch+Reveil
DTSTAMP:20100219T210000Z
DTSTART:20100219T210000
DTEND:20100219T235900
SUMMARY:Ethisch Reveil
DESCRIPTION:Gentle Veincut\, Fine China Superbone
\n\nhttp://www.last.fm/event/1358751+Ethisch+Reveil
LOCATION:Havenkwartier\, Netherlands
GEO:6.168775;52.24939
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1392314
URL;VALUE=URI:http://www.last.fm/event/1392314+The+Monolith+Deathcult+at+Burgerweeshuis+on+26+February+2010
DTSTAMP:20100226T150100Z
DTSTART:20100226T000000
DTEND:20100226T235900
SUMMARY:The Monolith Deathcult + Erebus at Burgerweeshuis
DESCRIPTION:The Monolith Deathcult\, Erebus
\n\nhttp://www.last.fm/event/1392314+The+Monolith+Deathcult+at+Burgerweeshuis+on+26+February+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1264138
URL;VALUE=URI:http://www.last.fm/event/1264138+Loefah+at+Burgerweeshuis+on+27+February+2010
DTSTAMP:20100227T163800Z
DTSTART:20100227T000000
DTEND:20100227T235900
SUMMARY:Loefah + 2562 + more at Burgerweeshuis
DESCRIPTION:Loefah\, 2562\, Cavemen
\n\nhttp://www.last.fm/event/1264138+Loefah+at+Burgerweeshuis+on+27+February+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1391634
URL;VALUE=URI:http://www.last.fm/event/1391634+JW+Roy+weet+het+zeker
DTSTAMP:20100227T203000Z
DTSTART:20100227T203000
DTEND:20100227T235900
SUMMARY:JW Roy weet het zeker
DESCRIPTION:JW Roy
\n\nhttp://www.last.fm/event/1391634+JW+Roy+weet+het+zeker
LOCATION:theater bouwkunde\, Netherlands
GEO:6.153073;52.253485
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1361670
URL;VALUE=URI:http://www.last.fm/event/1361670+Millennium+Jazz+Orchestra+at+Theater+Bouwkunde+on+3+March+2010
DTSTAMP:20100303T210000Z
DTSTART:20100303T210000
DTEND:20100303T235900
SUMMARY:Millennium Jazz Orchestra + Grant Stewart at Theater Bouwkunde
DESCRIPTION:Millennium Jazz Orchestra\, Grant Stewart
\n\nhttp://www.last.fm/event/1361670+Millennium+Jazz+Orchestra+at+Theater+Bouwkunde+on+3+March+2010
LOCATION:Theater Bouwkunde\, Netherlands
GEO:6.153074;52.253485
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1283011
URL;VALUE=URI:http://www.last.fm/event/1283011+Suimasen+at+Burgerweeshuis+on+6+March+2010
DTSTAMP:20100306T103300Z
DTSTART:20100306T000000
DTEND:20100306T235900
SUMMARY:Suimasen at Burgerweeshuis
DESCRIPTION:Suimasen
\n\nhttp://www.last.fm/event/1283011+Suimasen+at+Burgerweeshuis+on+6+March+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1302136
URL;VALUE=URI:http://www.last.fm/event/1302136+El+Pino+and+the+Volunteers+at+Burgerweeshuis+on+6+March+2010
DTSTAMP:20100306T203000Z
DTSTART:20100306T203000
DTEND:20100306T235900
SUMMARY:El Pino and the Volunteers + Coparck + more at Burgerweeshuis
DESCRIPTION:El Pino and the Volunteers\, Coparck\, Suimasen
\n\nhttp://www.last.fm/event/1302136+El+Pino+and+the+Volunteers+at+Burgerweeshuis+on+6+March+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1323437
URL;VALUE=URI:http://www.last.fm/event/1323437+Wouter+Hamel+theatertour
DTSTAMP:20100311T200000Z
DTSTART:20100311T200000
DTEND:20100311T235900
SUMMARY:Wouter Hamel theatertour
DESCRIPTION:Wouter Hamel
\n\nhttp://www.last.fm/event/1323437+Wouter+Hamel+theatertour
LOCATION:Deventer Schouwburg\, Netherlands
GEO:6.161613;52.255862
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1400318
URL;VALUE=URI:http://www.last.fm/event/1400318+Dobet+Gnahor%C3%A9
DTSTAMP:20100312T192400Z
DTSTART:20100312T000000
DTEND:20100312T235900
SUMMARY:Dobet Gnahoré
DESCRIPTION:Dobet Gnahoré
\n\nhttp://www.last.fm/event/1400318+Dobet+Gnahor%C3%A9
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1347202
URL;VALUE=URI:http://www.last.fm/event/1347202+Waylon
DTSTAMP:20100319T203000Z
DTSTART:20100319T203000
DTEND:20100319T235900
SUMMARY:Waylon
DESCRIPTION:Waylon
\n\nhttp://www.last.fm/event/1347202+Waylon
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1359365
URL;VALUE=URI:http://www.last.fm/event/1359365+NO+blues+%40+Bouwkunde
DTSTAMP:20100320T113800Z
DTSTART:20100320T000000
DTEND:20100320T235900
SUMMARY:NO blues @ Bouwkunde
DESCRIPTION:No Blues
\n\nhttp://www.last.fm/event/1359365+NO+blues+%40+Bouwkunde
LOCATION:Theater Bouwkunde\, Netherlands
GEO:6.153074;52.253485
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1394328
URL;VALUE=URI:http://www.last.fm/event/1394328+Schwarzblut+at+Burgerweeshuis+on+20+March+2010
DTSTAMP:20100320T200000Z
DTSTART:20100320T200000
DTEND:20100320T235900
SUMMARY:Schwarzblut + Deadcell + more at Burgerweeshuis
DESCRIPTION:Schwarzblut\, Deadcell\, animAdverto
\n\nhttp://www.last.fm/event/1394328+Schwarzblut+at+Burgerweeshuis+on+20+March+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1352883
URL;VALUE=URI:http://www.last.fm/event/1352883+King+Me+-+Cords+Live
DTSTAMP:20100326T200000Z
DTSTART:20100326T200000
DTEND:20100326T235900
SUMMARY:King Me & Cords Live
DESCRIPTION:King Me\, Cords
\n\nhttp://www.last.fm/event/1352883+King+Me+-+Cords+Live
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1322037
URL;VALUE=URI:http://www.last.fm/event/1322037+The+Opposites
DTSTAMP:20100403T203000Z
DTSTART:20100403T203000
DTEND:20100403T235900
SUMMARY:The Opposites
DESCRIPTION:The Opposites
\n\nhttp://www.last.fm/event/1322037+The+Opposites
LOCATION:Burgerweeshuis\, Netherlands
GEO:;
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1387725
URL;VALUE=URI:http://www.last.fm/event/1387725+Isbells+at+Burgerweeshuis+on+17+April+2010
DTSTAMP:20100417T210000Z
DTSTART:20100417T210000
DTEND:20100417T235900
SUMMARY:Isbells at Burgerweeshuis
DESCRIPTION:Isbells
\n\nhttp://www.last.fm/event/1387725+Isbells+at+Burgerweeshuis+on+17+April+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1326470
URL;VALUE=URI:http://www.last.fm/event/1326470+Anathema+at+Burgerweeshuis+on+22+April+2010
DTSTAMP:20100422T210000Z
DTSTART:20100422T210000
DTEND:20100422T235900
SUMMARY:Anathema at Burgerweeshuis
DESCRIPTION:Anathema
\n\nhttp://www.last.fm/event/1326470+Anathema+at+Burgerweeshuis+on+22+April+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1400310
URL;VALUE=URI:http://www.last.fm/event/1400310+Michiel+Borstlap+Eldorado
DTSTAMP:20100423T210000Z
DTSTART:20100423T210000
DTEND:20100423T235900
SUMMARY:Michiel Borstlap Eldorado
DESCRIPTION:Michiel Borstlap
\n\nhttp://www.last.fm/event/1400310+Michiel+Borstlap+Eldorado
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1067247
URL;VALUE=URI:http://www.last.fm/event/1067247+Heideroosjes+at+Burgerweeshuis+on+9+May+2010
DTSTAMP:20100509T203000Z
DTSTART:20100509T203000
DTEND:20100509T235900
SUMMARY:Heideroosjes at Burgerweeshuis
DESCRIPTION:Heideroosjes
\n\nhttp://www.last.fm/event/1067247+Heideroosjes+at+Burgerweeshuis+on+9+May+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1334621
URL;VALUE=URI:http://www.last.fm/event/1334621+Roosbeef+at+Deventer+Schouwburg+on+11+May+2010
DTSTAMP:20100511T200000Z
DTSTART:20100511T200000
DTEND:20100511T235900
SUMMARY:Roosbeef at Deventer Schouwburg
DESCRIPTION:Roosbeef
\n\nhttp://www.last.fm/event/1334621+Roosbeef+at+Deventer+Schouwburg+on+11+May+2010
LOCATION:Deventer Schouwburg\, Netherlands
GEO:6.161613;52.255862
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1136119
URL;VALUE=URI:http://www.last.fm/event/1136119+Heideroosjes
DTSTAMP:20100519T203000Z
DTSTART:20100519T203000
DTEND:20100519T235900
SUMMARY:Heideroosjes
DESCRIPTION:De Heideroosjes
\n\nhttp://www.last.fm/event/1136119+Heideroosjes
LOCATION:Deventer Schouwburg\, Netherlands
GEO:6.161613;52.255862
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1327278
URL;VALUE=URI:http://www.last.fm/event/1327278+Aesun+at+Burgerweeshuis+on+9+June+2010
DTSTAMP:20100609T112900Z
DTSTART:20100609T000000
DTEND:20100609T235900
SUMMARY:Aesun at Burgerweeshuis
DESCRIPTION:Aesun
\n\nhttp://www.last.fm/event/1327278+Aesun+at+Burgerweeshuis+on+9+June+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1334624
URL;VALUE=URI:http://www.last.fm/event/1334624+Peter+Pan+Speedrock+at+Burgerweeshuis+on+11+June+2010
DTSTAMP:20100611T203000Z
DTSTART:20100611T203000
DTEND:20100611T235900
SUMMARY:Peter Pan Speedrock + Paceshifters at Burgerweeshuis
DESCRIPTION:Peter Pan Speedrock\, Paceshifters
\n\nhttp://www.last.fm/event/1334624+Peter+Pan+Speedrock+at+Burgerweeshuis+on+11+June+2010
LOCATION:Burgerweeshuis\, Netherlands
GEO:6.155862;52.254341
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1405016
URL;VALUE=URI:http://www.last.fm/event/1405016+Ground+Zero+Festival
DTSTAMP:20100703T133200Z
DTSTART:20100703T000000
DTEND:20100703T235900
SUMMARY:Ground Zero Festival
DESCRIPTION:TBA
\n\nhttp://www.last.fm/event/1405016+Ground+Zero+Festival
LOCATION:Recreatieplas Bussloo\, Netherlands
GEO:6.1333333;52.2
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1398450
URL;VALUE=URI:http://www.last.fm/event/1398450+Xnoizz+Flevo+Festival
DTSTAMP:20100819T191900Z
DTSTART:20100819T000000
DTEND:20100822T235900
SUMMARY:Xnoizz Flevo Festival
DESCRIPTION:Nog Onbekend
\n\nhttp://www.last.fm/event/1398450+Xnoizz+Flevo+Festival
LOCATION:Recreatieplas Bussloo\, Netherlands
GEO:6.1333333;52.2
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1135861
URL;VALUE=URI:http://www.last.fm/event/1135861+A+Campingflight+to+Lowlands+Paradise+2010
DTSTAMP:20100820T160500Z
DTSTART:20100820T000000
DTEND:20100822T235900
SUMMARY:A Campingflight to Lowlands Paradise 2010
DESCRIPTION:Kees van Hondt
\n\nhttp://www.last.fm/event/1135861+A+Campingflight+to+Lowlands+Paradise+2010
LOCATION:Evenemententerrein Walibi World\, Netherlands
GEO:6.227748;52.24453
END:VEVENT
BEGIN:VEVENT
UID:LFMEVENT-1397948
URL;VALUE=URI:http://www.last.fm/event/1397948+Clock+live%40+Kajak
DTSTAMP:20100917T141500Z
DTSTART:20100917T000000
DTEND:20100917T235900
SUMMARY:Clock live@ Kajak
DESCRIPTION:Clockrockz
\n\nhttp://www.last.fm/event/1397948+Clock+live%40+Kajak
LOCATION:Kajak\, Netherlands
GEO:6.092868;52.236304
END:VEVENT
END:VCALENDAR
+558
View File
@@ -0,0 +1,558 @@
BEGIN:VCALENDAR
CALSCALE:GREGORIAN
PRODID:-//Ximian//NONSGML Evolution Calendar//EN
VERSION:2.0
BEGIN:VEVENT
UID:20060425T220628Z-5616-1000-1-25@embrace
DTSTART;VALUE=DATE:20061004
DTEND;VALUE=DATE:20061005
SEQUENCE:3
SUMMARY:GNOME 2.16.1 Stable Release
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T220833Z-5616-1000-1-90@embrace
DTSTART;VALUE=DATE:20070108
DTEND;VALUE=DATE:20070109
SEQUENCE:4
SUMMARY:GNOME 2.17.5 Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.17.5 Tarballs Due
TRANSP:TRANSPARENT
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T154249Z-17150-100-8562-140@embrace
DESCRIPTION:GNOME 2.17.5 Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:20060425T221010Z-5616-1000-1-48@embrace
DTSTART;VALUE=DATE:20070110
DTEND;VALUE=DATE:20070111
SEQUENCE:4
SUMMARY:GNOME 2.17.5 Development Release
CLASS:PUBLIC
LAST-MODIFIED:20060830T041844
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T220746Z-5616-1000-1-32@embrace
DTSTART;VALUE=DATE:20061204
DTEND;VALUE=DATE:20061205
SEQUENCE:4
SUMMARY:GNOME 2.17.3 Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.17.3 Tarballs Due
TRANSP:TRANSPARENT
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T154209Z-17150-100-8562-122@embrace
DESCRIPTION:GNOME 2.17.3 Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:20060425T220728Z-5616-1000-1-30@embrace
DTSTART;VALUE=DATE:20061120
DTEND;VALUE=DATE:20061121
SEQUENCE:4
SUMMARY:GNOME 2.16.2 Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.16.2 Tarballs Due
TRANSP:TRANSPARENT
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T154149Z-17150-100-8562-113@embrace
DESCRIPTION:GNOME 2.16.2 Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:20060425T220857Z-5616-1000-1-38@embrace
DTSTART;VALUE=DATE:20070122
DTEND;VALUE=DATE:20070123
SEQUENCE:4
SUMMARY:GNOME 2.18.0 Beta 1 (2.17.90) Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.18.0 Beta 1 (2.17.90) Tarballs Due
TRANSP:TRANSPARENT
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T154337Z-17150-100-8562-161@embrace
DESCRIPTION:GNOME 2.18.0 Beta 1 (2.17.90) Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:20060425T221522Z-5616-1000-1-53@embrace
DTSTART;VALUE=DATE:20061218
DTEND;VALUE=DATE:20061219
SEQUENCE:5
SUMMARY:UI Change Announcement Period
CLASS:PUBLIC
LAST-MODIFIED:20060830T041923
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
DESCRIPTION:UI Change Announcement Period: All user interface changes
must be announced to gnome-doc-list@.
END:VEVENT
BEGIN:VEVENT
UID:20060425T221023Z-5616-1000-1-45@embrace
DTSTART;VALUE=DATE:20070228
DTEND;VALUE=DATE:20070301
SEQUENCE:3
SUMMARY:GNOME 2.18.0 Release Candidate (2.17.92)
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T221110Z-5616-1000-1-48@embrace
DTSTART;VALUE=DATE:20070314
DTEND;VALUE=DATE:20070315
SEQUENCE:3
SUMMARY:GNOME 2.18.0 Final Release!
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060826T130552Z-26732-100-1-50@embrace
DTSTAMP:20060826T130552Z
DTSTART;VALUE=DATE:20070411
DTEND;VALUE=DATE:20070412
TRANSP:TRANSPARENT
SEQUENCE:3
SUMMARY:Gnome 2.18.1 Stable Release
CLASS:PUBLIC
CREATED:20060830T041843
LAST-MODIFIED:20060830T041843
END:VEVENT
BEGIN:VEVENT
UID:20060425T220931Z-5616-1000-1-39@embrace
DTSTART;VALUE=DATE:20070124
DTEND;VALUE=DATE:20070125
SEQUENCE:3
SUMMARY:GNOME 2.18.0 Beta 1 (2.17.90) Release
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T220950Z-5616-1000-1-41@embrace
DTSTART;VALUE=DATE:20070131
DTEND;VALUE=DATE:20070201
SEQUENCE:3
SUMMARY:GNOME 2.16.3 Stable Release
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T220719Z-5616-1000-1-29@embrace
DTSTART;VALUE=DATE:20061108
DTEND;VALUE=DATE:20061109
SEQUENCE:3
SUMMARY:GNOME 2.17.2 Development Release
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060825T153859Z-23493-100-1-52@embrace
DTSTAMP:20060825T153859Z
DTSTART;VALUE=DATE-TIME;
TZID=/softwarestudio.org/Olson_20011030_5/America/New_York:
20061007
DTEND;VALUE=DATE-TIME;
TZID=/softwarestudio.org/Olson_20011030_5/America/New_York:
20061009T235900
SUMMARY:Boston Summit 2006
CREATED:20060830T041843
LAST-MODIFIED:20060830T041843
CLASS:PUBLIC
TRANSP:TRANSPARENT
SEQUENCE:2
DESCRIPTION:http://live.gnome.org/Boston2006
END:VEVENT
BEGIN:VEVENT
UID:20060425T221353Z-5616-1000-1-51@embrace
DTSTART;VALUE=DATE:20061016
DTEND;VALUE=DATE:20061017
SEQUENCE:3
SUMMARY:End of new (app) modules proposal period
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T222220Z-5616-1000-1-58@embrace
DTSTART;VALUE=DATE:20070122
DTEND;VALUE=DATE:20070123
SEQUENCE:3
SUMMARY:UI Freeze
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:UI Freeze: No UI changes may be made without approval from
the release-team and notification to the GDP.
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T221534Z-5616-1000-1-54@embrace
DTSTART;VALUE=DATE:20061218
DTEND;VALUE=DATE:20061219
SEQUENCE:5
SUMMARY:Module inclusion discussion heats up.
CLASS:PUBLIC
LAST-MODIFIED:20060830T041915
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T220717Z-5616-1000-1-29@embrace
DTSTART;VALUE=DATE:20070214
DTEND;VALUE=DATE:20070215
SEQUENCE:2
SUMMARY:GNOME 2.18.0 Beta 2 (2.17.91)
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T221117Z-5616-1000-1-49@embrace
DTSTART;VALUE=DATE:20070409
DTEND;VALUE=DATE:20070410
SEQUENCE:4
SUMMARY:GNOME 2.18.1 Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.18.1 Tarballs Due
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T220832Z-5616-1000-1-36@embrace
DTSTART;VALUE=DATE:20061218
DTEND;VALUE=DATE:20061219
SEQUENCE:4
SUMMARY:GNOME 2.17.4 Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.17.4 Tarballs Due
TRANSP:TRANSPARENT
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T154230Z-17150-100-8562-131@embrace
DESCRIPTION:GNOME 2.17.4 Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:20060425T220644Z-5616-1000-1-26@embrace
DTSTART;VALUE=DATE:20061016
DTEND;VALUE=DATE:20061017
SEQUENCE:6
SUMMARY:GNOME 2.17.1 Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.17.1 Tarballs Due
TRANSP:TRANSPARENT
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T153408Z-17150-100-8562-82@embrace
DESCRIPTION:GNOME 2.17.1 Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:20060425T220752Z-5616-1000-1-33@embrace
DTSTART;VALUE=DATE:20061206
DTEND;VALUE=DATE:20061207
SEQUENCE:3
SUMMARY:GNOME 2.17.3 Development Release
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T220658Z-5616-1000-1-27@embrace
DTSTART;VALUE=DATE:20061018
DTEND;VALUE=DATE:20061019
SEQUENCE:3
SUMMARY:GNOME 2.17.1 Development Release
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T222230Z-5616-1000-1-59@embrace
DTSTART;VALUE=DATE:20070115
DTEND;VALUE=DATE:20070116
SEQUENCE:3
SUMMARY:New APIs must be fully documented
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T220736Z-5616-1000-1-31@embrace
DTSTART;VALUE=DATE:20061122
DTEND;VALUE=DATE:20061123
SEQUENCE:3
SUMMARY:GNOME 2.16.2 Stable Release
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T222150Z-5616-1000-1-56@embrace
DTSTART;VALUE=DATE:20070108
DTEND;VALUE=DATE:20070109
SEQUENCE:4
SUMMARY:API/ABI Freeze
CLASS:PUBLIC
LAST-MODIFIED:20060830T041946
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
DESCRIPTION:API/ABI Freeze: developer APIs should be frozen at this
point.
END:VEVENT
BEGIN:VEVENT
UID:20060425T221019Z-5616-1000-1-44@embrace
DTSTART;VALUE=DATE:20070226
DTEND;VALUE=DATE:20070227
SEQUENCE:4
SUMMARY:GNOME 2.18.0 Release Candidate (2.17.92) Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.18.0 Release Candidate (2.17.92) Tarballs Due
TRANSP:TRANSPARENT
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T154458Z-17150-100-8562-197@embrace
DESCRIPTION:GNOME 2.18.0 Release Candidate (2.17.92) Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:20060425T221031Z-5616-1000-1-46@embrace
DTSTART;VALUE=DATE:20070305
DTEND;VALUE=DATE:20070306
SEQUENCE:4
SUMMARY:Hard Code Freeze
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:Hard Code Freeze: no source code changes can be made without
approval from the release-team. Translation and documentation can
continue.
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T220955Z-5616-1000-1-42@embrace
DTSTART;VALUE=DATE:20070212
DTEND;VALUE=DATE:20070213
SEQUENCE:4
SUMMARY:GNOME 2.18.0 Beta 2 (2.17.91) Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.18.0 Beta 2 (2.17.91) Tarballs Due
TRANSP:TRANSPARENT
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T154441Z-17150-100-8562-188@embrace
DESCRIPTION:GNOME 2.18.0 Beta 2 (2.17.91) Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:20060425T221051Z-5616-1000-1-47@embrace
DTSTART;VALUE=DATE:20070312
DTEND;VALUE=DATE:20070313
SEQUENCE:6
SUMMARY:GNOME 2.18.0 Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
DESCRIPTION:Hard Code Freeze ends\, but other freezes remain in effect
for the stable branch.
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T155041Z-17150-100-8562-228@embrace
DESCRIPTION:GNOME 2.18.0 Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:20060425T220850Z-5616-1000-1-37@embrace
DTSTART;VALUE=DATE:20061220
DTEND;VALUE=DATE:20061221
SEQUENCE:3
SUMMARY:GNOME 2.17.4 Development Release
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T222207Z-5616-1000-1-57@embrace
DTSTART;VALUE=DATE:20070108
DTEND;VALUE=DATE:20070109
SEQUENCE:4
SUMMARY:Feature and Module Freeze
CLASS:PUBLIC
LAST-MODIFIED:20060830T041953
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
DESCRIPTION:Feature and Module Freeze: new modules and functionality are
chosen now.
END:VEVENT
BEGIN:VEVENT
UID:20060425T220941Z-5616-1000-1-40@embrace
DTSTART;VALUE=DATE:20070129
DTEND;VALUE=DATE:20070130
SEQUENCE:4
SUMMARY:GNOME 2.16.3 Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.16.3 Tarballs Due
TRANSP:TRANSPARENT
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T154417Z-17150-100-8562-179@embrace
DESCRIPTION:GNOME 2.16.3 Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:20060425T221456Z-5616-1000-1-52@embrace
DTSTART;VALUE=DATE:20061218
DTEND;VALUE=DATE:20061219
SEQUENCE:5
SUMMARY:String Change Announcement Period
CLASS:PUBLIC
LAST-MODIFIED:20060830T041858
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
TRANSP:TRANSPARENT
DESCRIPTION:String Change Announcement Period: All string changes must be
announced to both gnome-i18n@ and gnome-doc-list@.
END:VEVENT
BEGIN:VEVENT
UID:20060425T221625Z-5616-1000-1-55@embrace
DTSTART;VALUE=DATE:20070212
DTEND;VALUE=DATE:20070213
SEQUENCE:3
SUMMARY:String Freeze
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:String Freeze: no string changes may be made without
confirmation from the l10n team (gnome-i18n@) and notification to both
the release team and the GDP (gnome-doc-list@).
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
UID:20060425T220717Z-5616-1000-1-28@embrace
DTSTART;VALUE=DATE:20061002
DTEND;VALUE=DATE:20061003
SEQUENCE:5
SUMMARY:GNOME 2.16.1 Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
X-EVOLUTION-ALARM-UID:20060425T221751Z-9858-1000-1-227@embrace
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.16.1 Tarballs Due
TRANSP:TRANSPARENT
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T154129Z-17150-100-8562-104@embrace
DESCRIPTION:GNOME 2.16.1 Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:20060425T220714Z-5616-1000-1-28@embrace
DTSTART;VALUE=DATE:20061106
DTEND;VALUE=DATE:20061107
SEQUENCE:4
SUMMARY:GNOME 2.17.2 Tarballs Due
CLASS:PUBLIC
LAST-MODIFIED:20060830T041843
X-EVOLUTION-ALARM-UID:20060425T221750Z-9858-1000-1-227@embrace
DTSTAMP:20060825T152756Z
CREATED:20060830T041843
DESCRIPTION:GNOME 2.17.2 Tarballs Due
TRANSP:TRANSPARENT
BEGIN:VALARM
X-EVOLUTION-ALARM-UID:20060825T154128Z-17150-100-8562-104@embrace
DESCRIPTION:GNOME 2.17.2 Tarballs Due
ACTION:DISPLAY
TRIGGER;VALUE=DURATION;RELATED=START:-P1D
END:VALARM
END:VEVENT
END:VCALENDAR
+190
View File
@@ -0,0 +1,190 @@
BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:Belgische feestdagen
PRODID:-//Apple Computer\, Inc//iCal 1.5//EN
X-WR-RELCALID:440DCB50-039E-11D8-88DC-000A957CAA38-CALP
X-WR-TIMEZONE:Europe/Brussels
CALSCALE:GREGORIAN
X-WR-CALDESC:Alle officiële Belgische feestdagen van 2001 tot en met 200
5.
BEGIN:VEVENT
DTSTART;VALUE=DATE:20041101
DTEND;VALUE=DATE:20041102
SUMMARY:Allerheiligen
UID:8684B909-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20050101
DTEND;VALUE=DATE:20050102
SUMMARY:Nieuwjaar
UID:868590B6-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040520
DTEND;VALUE=DATE:20040521
SUMMARY:O.-L.-Heer Hemelvaart
UID:8684D7A0-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20050711
DTEND;VALUE=DATE:20050712
SUMMARY:Feest van de Vlaamse Gemeenschap
UID:86850D80-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20041115
DTEND;VALUE=DATE:20041116
SUMMARY:Feest van de Dynastie
UID:86856160-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040721
DTEND;VALUE=DATE:20040722
SUMMARY:Nationale Feestdag
UID:86857F3C-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040501
DTEND;VALUE=DATE:20040502
SUMMARY:Feest van de Arbeid
UID:86857095-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20050721
DTEND;VALUE=DATE:20050722
SUMMARY:Nationale Feestdag
UID:86858207-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20041225
DTEND;VALUE=DATE:20041226
SUMMARY:Christmas Day
UID:8685BD6D-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20051115
DTEND;VALUE=DATE:20051116
SUMMARY:Feest van de Dynastie
UID:8685648E-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040711
DTEND;VALUE=DATE:20040712
SUMMARY:Feest van de Vlaamse Gemeenschap
UID:86850AA0-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20050501
DTEND;VALUE=DATE:20050502
SUMMARY:Feest van de Arbeid
UID:86857376-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20051101
DTEND;VALUE=DATE:20051102
SUMMARY:Allerheiligen
UID:8684BC36-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20050327
DTEND;VALUE=DATE:20050328
SUMMARY:Pasen
UID:8685327B-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040531
DTEND;VALUE=DATE:20040601
SUMMARY:Pinkstermaandag
UID:86859C8A-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20051111
DTEND;VALUE=DATE:20051112
SUMMARY:Wapenstilstand
UID:8684CB8D-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20051225
DTEND;VALUE=DATE:20051226
SUMMARY:Kerstmis
UID:8684FEAD-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20041225
DTEND;VALUE=DATE:20041226
SUMMARY:Kerstmis
UID:8684FB9D-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20050927
DTEND;VALUE=DATE:20050928
SUMMARY:Feest van de Waalse Gemeenschap
UID:868523A1-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20050516
DTEND;VALUE=DATE:20050517
SUMMARY:Pinkstermaandag
UID:86859F92-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20050505
DTEND;VALUE=DATE:20050506
SUMMARY:O.-L.-Heer Hemelvaart
UID:8684DA8A-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040411
DTEND;VALUE=DATE:20040412
SUMMARY:Pasen
UID:86852F70-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040927
DTEND;VALUE=DATE:20040928
SUMMARY:Feest van de Waalse Gemeenschap
UID:86852086-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20050328
DTEND;VALUE=DATE:20050329
SUMMARY:Paasmaandag
UID:868542CD-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20041111
DTEND;VALUE=DATE:20041112
SUMMARY:Wapenstilstand
UID:8684C881-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040412
DTEND;VALUE=DATE:20040413
SUMMARY:Paasmaandag
UID:86853FA8-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20050815
DTEND;VALUE=DATE:20050816
SUMMARY:Maria Tenhemelopneming
UID:8684EE84-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20050515
DTEND;VALUE=DATE:20050516
SUMMARY:Pinksteren
UID:8685B0B5-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040530
DTEND;VALUE=DATE:20040531
SUMMARY:Pinksteren
UID:8685AD9E-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040815
DTEND;VALUE=DATE:20040816
SUMMARY:Maria Tenhemelopneming
UID:8684E6C3-1F33-11D7-B218-000393A91E82-RID
END:VEVENT
END:VCALENDAR
+331
View File
@@ -0,0 +1,331 @@
BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:Dryway
PRODID:-//Apple Computer\, Inc//iCal 1.5//EN
X-WR-RELCALID:9618E652-9A12-11D9-B9F6-003065D7F79A-CALP
X-WR-TIMEZONE:US/Eastern
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:US/Eastern
LAST-MODIFIED:20050420T000902Z
BEGIN:DAYLIGHT
DTSTART:20050403T070000
TZOFFSETTO:-0400
TZOFFSETFROM:+0000
TZNAME:EDT
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20051030T020000
TZOFFSETTO:-0500
TZOFFSETFROM:-0400
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:20060402T010000
TZOFFSETTO:-0400
TZOFFSETFROM:-0500
TZNAME:EDT
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20050321T140804Z
UID:9529B04C-9A10-11D9-B9F6-003065D7F79A-RID
DTSTART;TZID=US/Eastern:20050528T100000
SUMMARY:Dry Way
DTEND;TZID=US/Eastern:20050528T150000
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050624T100000
SUMMARY:Dry Way
UID:CE57543A-9A5D-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230707Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050612T100000
SUMMARY:Dryway
UID:DF840B5C-9A5D-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T020207Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050618T100000
SUMMARY:Dryway
UID:E9403F63-9A5D-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T020156Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050619T100000
SUMMARY:Dryway
UID:EF7BAD31-9A5D-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T020211Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050625T100000
SUMMARY:Dryway
UID:F34776C8-9A5D-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T020201Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050708T100000
SUMMARY:Dry Way
UID:F4910F82-9A5D-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230737Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050626T100000
SUMMARY:Dryway
UID:F8A5E477-9A5D-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T020215Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050702T100000
SUMMARY:Dryway
UID:FA4FEEB4-9A5D-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T020223Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050703T100000
SUMMARY:Dryway
UID:02390F02-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T020220Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050710T100000
SUMMARY:Dry Way
UID:052543DA-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230741Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050807T100000
SUMMARY:Dry Way
UID:088A8B2D-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230902Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050717T100000
SUMMARY:Dry Way
UID:0AE6CC6A-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230748Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050729T100000
SUMMARY:Dry Way
UID:0FBDCD3D-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230756Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050730T100000
SUMMARY:Dry Way
UID:119B40AD-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230759Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050731T100000
SUMMARY:Dry Way
UID:1AD30FA1-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230815Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050806T100000
SUMMARY:Dry Way
UID:33CAF8EE-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230857Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050812T100000
SUMMARY:Dry Way
UID:377F37E6-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230906Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050814T100000
SUMMARY:Dry Way
UID:3B8484C7-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230915Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050820T100000
SUMMARY:Dry Way
UID:3FB197BA-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230919Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050821T100000
SUMMARY:Dry Way
UID:417A378D-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230922Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050904T100000
SUMMARY:Dryway
UID:439E54E7-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T015827Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050826T100000
SUMMARY:Dry Way
UID:468221CC-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230928Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050819T100000
SUMMARY:Dry Way
UID:48966612-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T020128Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050828T100000
SUMMARY:Dry Way
UID:4B4CE621-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T230936Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050903T100000
SUMMARY:Dryway
UID:4EB4963F-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T015805Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050917T100000
SUMMARY:Dryway
UID:527DD9D5-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T015833Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20051009T100000
SUMMARY:Dry Way
UID:544182EE-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T231026Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20050918T100000
SUMMARY:Dryway
UID:56D4C4F4-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050322T015840Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20051008T100000
SUMMARY:Dry Way
UID:670DB0B6-9A5E-11D9-ACB4-003065D7F79A-RID
DTSTAMP:20050321T231023Z
DESCRIPTION:Friday releases are for four hours and begin at 11 a.m. Satu
rday releases last five hours and begin at 10 a.m. Sunday releases are f
our hours beginning at 10 a.m. Flows vary from 900-1100 cfs.
DURATION:PT5H
END:VEVENT
END:VCALENDAR
+116
View File
@@ -0,0 +1,116 @@
BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:EXDATE Example
PRODID:-//Apple Computer\, Inc//iCal 2.0//EN
X-WR-RELCALID:6E3F1EC4-081F-4DBF-8261-8938E9B32A70
X-WR-TIMEZONE:Australia/Brisbane
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VTIMEZONE
TZID:Australia/Brisbane
LAST-MODIFIED:20051209T055354Z
BEGIN:STANDARD
DTSTART:19321213T204552
TZOFFSETTO:+1000
TZOFFSETFROM:+0000
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19420101T030000
TZOFFSETTO:+1100
TZOFFSETFROM:+1000
TZNAME:EST
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:19420329T020000
TZOFFSETTO:+1000
TZOFFSETFROM:+1100
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19420927T030000
TZOFFSETTO:+1100
TZOFFSETFROM:+1000
TZNAME:EST
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:19430328T020000
TZOFFSETTO:+1000
TZOFFSETFROM:+1100
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19431003T030000
TZOFFSETTO:+1100
TZOFFSETFROM:+1000
TZNAME:EST
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:19440326T020000
TZOFFSETTO:+1000
TZOFFSETFROM:+1100
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19711031T030000
TZOFFSETTO:+1100
TZOFFSETFROM:+1000
TZNAME:EST
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:19720227T030000
TZOFFSETTO:+1000
TZOFFSETFROM:+1100
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19891029T030000
TZOFFSETTO:+1100
TZOFFSETFROM:+1000
TZNAME:EST
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:19900304T030000
TZOFFSETTO:+1000
TZOFFSETFROM:+1100
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19901028T030000
TZOFFSETTO:+1100
TZOFFSETFROM:+1000
TZNAME:EST
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:19910303T030000
TZOFFSETTO:+1000
TZOFFSETFROM:+1100
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19911027T030000
TZOFFSETTO:+1100
TZOFFSETFROM:+1000
TZNAME:EST
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:19920301T030000
TZOFFSETTO:+1000
TZOFFSETFROM:+1100
TZNAME:EST
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DURATION:PT1H
DTSTAMP:20051209T055346Z
EXDATE;TZID=Australia/Brisbane:20060103T120000
EXDATE;TZID=Australia/Brisbane:20060104T120000
UID:DDC5B090-1B46-4C1D-8A3C-D305516A0B90
SEQUENCE:9
DTSTART;TZID=Australia/Brisbane:20060101T120000
SUMMARY:Repeating Event
DESCRIPTION:This is a simple repeating event with a couple of days exclu
ded.
RRULE:FREQ=DAILY;INTERVAL=1;COUNT=7
END:VEVENT
END:VCALENDAR
+220
View File
@@ -0,0 +1,220 @@
BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:2005 Chicago Bears Schedule
PRODID:-//Apple Computer\, Inc//iCal 1.5//EN
X-WR-RELCALID:202B7038-AFA2-11D9-A2CD-000A95AD128C
X-WR-TIMEZONE:Canada/Mountain
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VTIMEZONE
TZID:Canada/Mountain
LAST-MODIFIED:20050418T003804Z
BEGIN:DAYLIGHT
DTSTART:20050403T090000
TZOFFSETTO:-0600
TZOFFSETFROM:+0000
TZNAME:MDT
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20051030T020000
TZOFFSETTO:-0700
TZOFFSETFROM:-0600
TZNAME:MST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:20060402T010000
TZOFFSETTO:-0600
TZOFFSETFROM:-0700
TZNAME:MDT
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20050808T180000
DTEND;TZID=Canada/Mountain:20050808T210000
SUMMARY:Dolphins @ Bears
UID:202B352E-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:2
DTSTAMP:20050418T000046Z
DESCRIPTION:Preseason
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20050812T180000
DTEND;TZID=Canada/Mountain:20050812T210000
SUMMARY:Bears @ Rams
UID:202B39C2-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:2
DTSTAMP:20050418T000056Z
DESCRIPTION:Preseason
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20050820T180000
DTEND;TZID=Canada/Mountain:20050820T210000
SUMMARY:Bears @ Colts
UID:202B3BCB-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:2
DTSTAMP:20050418T000107Z
DESCRIPTION:Preseason
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20050826T180000
DTEND;TZID=Canada/Mountain:20050826T210000
SUMMARY:Bills @ Bears
UID:202B3DA8-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:3
DTSTAMP:20050418T000120Z
DESCRIPTION:Preseason
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20050901T180000
DTEND;TZID=Canada/Mountain:20050901T210000
SUMMARY:Browns @ Bears
UID:202B3F83-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:2
DTSTAMP:20050418T000134Z
DESCRIPTION:Preseason
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20050911T110000
DTEND;TZID=Canada/Mountain:20050911T140000
SUMMARY:Bears @ Redskins
UID:202B4156-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T000400Z
DESCRIPTION:Week #1
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20050918T110000
DTEND;TZID=Canada/Mountain:20050918T140000
SUMMARY:Lions @ Bears
UID:202B4354-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T000354Z
DESCRIPTION:Week #2
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20050925T110000
DTEND;TZID=Canada/Mountain:20050925T140000
SUMMARY:Bengals @ Bears
UID:202B4522-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T000509Z
DESCRIPTION:Week #3
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051009T110000
DTEND;TZID=Canada/Mountain:20051009T140000
SUMMARY:Bears @ Browns
UID:202B46E9-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T000809Z
DESCRIPTION:Week #4
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051016T110000
DTEND;TZID=Canada/Mountain:20051016T140000
SUMMARY:Vikings @ Bears
UID:202B48CF-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T000821Z
DESCRIPTION:Week #5
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051023T141500
DTEND;TZID=Canada/Mountain:20051023T171500
SUMMARY:Ravens @ Bears
UID:202B4AA6-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T000838Z
DESCRIPTION:Week #6
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051030T110000
DTEND;TZID=Canada/Mountain:20051030T140000
SUMMARY:Bears @ Lions
UID:202B4C7A-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T001042Z
DESCRIPTION:Week #7
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051106T110000
DTEND;TZID=Canada/Mountain:20051106T140000
SUMMARY:Bears @ Saints
UID:202B4E54-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T001115Z
DESCRIPTION:Week #8
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051113T110000
DTEND;TZID=Canada/Mountain:20051113T140000
SUMMARY:49ers @ Bears
UID:202B5047-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T001149Z
DESCRIPTION:Week #9
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051120T110000
DTEND;TZID=Canada/Mountain:20051120T140000
SUMMARY:Panthers @ Bears
UID:202B5215-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T001229Z
DESCRIPTION:Week #10
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051127T110000
DTEND;TZID=Canada/Mountain:20051127T140000
SUMMARY:Bears @ Buccaneers
UID:202B53E0-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T001322Z
DESCRIPTION:Week #11
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051204T110000
DTEND;TZID=Canada/Mountain:20051204T140000
SUMMARY:Packers @ Bears
UID:202B55AB-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T001408Z
DESCRIPTION:Week #12
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051211T110000
DTEND;TZID=Canada/Mountain:20051211T140000
SUMMARY:Bears @ Steelers
UID:202B5795-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T001555Z
DESCRIPTION:Week #13
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051218T193000
DTEND;TZID=Canada/Mountain:20051218T223000
SUMMARY:Falcons @ Bears
UID:202B5963-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:8
DTSTAMP:20050418T001943Z
DESCRIPTION:Week #14
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20051225T150000
DTEND;TZID=Canada/Mountain:20051225T180000
SUMMARY:Bears @ Packers
UID:202B5B31-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:3
DTSTAMP:20050418T001737Z
DESCRIPTION:Week #15
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Canada/Mountain:20060101T110000
DTEND;TZID=Canada/Mountain:20060101T140000
SUMMARY:Bears @ Vikings
UID:202B5D2A-AFA2-11D9-A2CD-000A95AD128C
SEQUENCE:4
DTSTAMP:20050418T001843Z
DESCRIPTION:Week #16
END:VEVENT
END:VCALENDAR
+341
View File
@@ -0,0 +1,341 @@
BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME;VALUE=TEXT:Lord of the Rings
PRODID:-//Apple Computer\, Inc//iCal 1.0//EN
X-WR-RELCALID;VALUE=TEXT:B4B8ABF0-1ECC-11D7-A916-00306553216E
X-WR-TIMEZONE;VALUE=TEXT:America/New_York
CALSCALE:GREGORIAN
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040105
DTEND;VALUE=DATE:20040106
SUMMARY:Fellowship enters Moria
UID:C1C7837D-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=1
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040109
DTEND;VALUE=DATE:20040110
SUMMARY:Fellowship reaches Lorien
UID:C1C78BF1-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=1
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040117
DTEND;VALUE=DATE:20040118
SUMMARY:Passing of Gandalf
UID:C1C79008-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=1
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040207
DTEND;VALUE=DATE:20040208
SUMMARY:Fellowship leaves Lorien
UID:C1C793E2-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040217
DTEND;VALUE=DATE:20040218
SUMMARY:Death of Boromir
UID:C1C797C6-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040220
DTEND;VALUE=DATE:20040221
SUMMARY:Meriadoc & Pippin meet Treebeard
UID:C1C79D1B-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040222
DTEND;VALUE=DATE:20040223
SUMMARY:Passing of King Ellesar
UID:C1C7A154-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040224
DTEND;VALUE=DATE:20040225
SUMMARY:Ents destroy Isengard
UID:C1C7A52F-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040226
DTEND;VALUE=DATE:20040227
SUMMARY:Aragorn takes the Paths of the Dead
UID:C1C7A939-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040305
DTEND;VALUE=DATE:20040306
SUMMARY:Frodo & Samwise encounter Shelob
UID:C1C7AD08-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=3
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040308
DTEND;VALUE=DATE:20040309
SUMMARY:Deaths of Denethor & Theoden
UID:C1C7B0EF-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=3
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040318
DTEND;VALUE=DATE:20040319
SUMMARY:Destruction of the Ring
UID:C1C7B4A3-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;UNTIL=20030317;BYMONTH=3
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040329
DTEND;VALUE=DATE:20040330
SUMMARY:Flowering of the Mallorn
UID:C1C7B8A3-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=3
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040404
DTEND;VALUE=DATE:20040405
SUMMARY:Gandalf visits Bilbo
UID:C1C7BD03-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040417
DTEND;VALUE=DATE:20040418
SUMMARY:An unexpected party
UID:C1C7C0EF-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040423
DTEND;VALUE=DATE:20040424
SUMMARY:Crowning of King Ellesar
UID:C1C7C4DA-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040519
DTEND;VALUE=DATE:20040520
SUMMARY:Arwen leaves Lorian to wed King Ellesar
UID:C1C7C8A0-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=5
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040611
DTEND;VALUE=DATE:20040612
SUMMARY:Sauron attacks Osgilliath
UID:C1C7CC66-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040613
DTEND;VALUE=DATE:20040614
SUMMARY:Bilbo returns to Bag End
UID:C1C7D01C-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040623
DTEND;VALUE=DATE:20040624
SUMMARY:Wedding of Ellesar & Arwen
UID:C1C7D3CE-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040704
DTEND;VALUE=DATE:20040705
SUMMARY:Gandalf imprisoned by Saruman
UID:C1C7D799-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=7
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040724
DTEND;VALUE=DATE:20040725
SUMMARY:The ring comes to Bilbo
UID:C1C7DB68-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=7
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040726
DTEND;VALUE=DATE:20040727
SUMMARY:Bilbo rescued from Wargs by Eagles
UID:C1C7DF1F-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=7
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040803
DTEND;VALUE=DATE:20040804
SUMMARY:Funeral of King Theoden
UID:C1C7E2CC-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=8
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040829
DTEND;VALUE=DATE:20040830
SUMMARY:Saruman enters the Shire
UID:C1C7E6A6-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=8
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040910
DTEND;VALUE=DATE:20040911
SUMMARY:Gandalf escapes from Orthanc
UID:C1C7EA5F-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040914
DTEND;VALUE=DATE:20040915
SUMMARY:Frodo & Bilbo's birthday
UID:C1C7EE1A-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040915
DTEND;VALUE=DATE:20040916
SUMMARY:Black riders enter the Shire
UID:C1C7F1FC-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040918
DTEND;VALUE=DATE:20040919
SUMMARY:Frodo and company rescued by Bombadil
UID:C1C7F5C4-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20040928
DTEND;VALUE=DATE:20040929
SUMMARY:Frodo wounded at Weathertop
UID:C1C7F99E-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20041016
DTEND;VALUE=DATE:20041017
SUMMARY:Boromir reaches Rivendell
UID:C1C7FD5C-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=10
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20041017
DTEND;VALUE=DATE:20041018
SUMMARY:Council of Elrond
UID:C1C80115-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=10
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20041025
DTEND;VALUE=DATE:20041026
SUMMARY:End of War of the Ring
UID:C1C804F4-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=10
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20041116
DTEND;VALUE=DATE:20041117
SUMMARY:Bilbo reaches the Lonely Mountain
UID:C1C808BE-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=11
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20041205
DTEND;VALUE=DATE:20041206
SUMMARY:Death of Smaug
UID:C1C80C80-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=12
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20041216
DTEND;VALUE=DATE:20041217
SUMMARY:Fellowship begins Quest
UID:C1C8107A-1ECC-11D7-A916-00306553216E
DTSTAMP:20040103T033800Z
SEQUENCE:1
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=12
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20030325
DTEND;VALUE=DATE:20030326
SUMMARY:Destruction of the Ring
UID:5EAE2BA6-D5D6-11D7-BA10-000393D17D20
DTSTAMP:20030824T015752Z
SEQUENCE:2
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=3
END:VEVENT
END:VCALENDAR
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
BEGIN:VCARD
VERSION:2.1
UID:urn:uuid:37ca3098-8a4a-5bc3-8069-e2d958d71dbb
EMAIL;PREF:[email protected]
CATEGORIES:My Contacts
END:VCARD
+6
View File
@@ -0,0 +1,6 @@
BEGIN:VCARD
VERSION:2.1
UID:urn:uuid:a426dfa3-8dde-5a29-9d07-9f025640b257
EMAIL;PREF:[email protected]
CATEGORIES:My Contacts
END:VCARD
+8
View File
@@ -0,0 +1,8 @@
BEGIN:VCARD
VERSION:2.1
UID:urn:uuid:8ed76e29-9e7d-5a94-bc69-3ac49f5f0fc7
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=20=C3=91=20=C3=91=20=C3=91=20;;;;
FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20
TEL;CELL;PREF:123456789
CATEGORIES:My Contacts
END:VCARD
+19
View File
@@ -0,0 +1,19 @@
BEGIN:VCARD
VERSION:2.1
UID:urn:uuid:35ad7acc-830e-53e3-abdd-4e3586afcf94
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=
=20=C3=91=20=C3=91=20=C3=91;;;;
FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=
=20=C3=91=20=C3=91=20=C3=91
TEL;CELL;PREF:123456
TEL;HOME:234567
TEL;CELL:3456789
TEL;HOME:45678901
CATEGORIES:My Contacts
NOTE;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=
=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=
=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20
NOTE;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=
=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=
=C3=91=20=C3=91=20=C3=91=20=C3=91=20=C3=91=20
END:VCARD
+36
View File
@@ -0,0 +1,36 @@
BEGIN:VCARD
VERSION:2.1
UID:urn:uuid:1a9dc94d-733b-50a3-8875-3d587274afc8
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=20=C3=91=20;=C3=91=20=C3=91=20=C3=91=20;;;
FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=20=C3=91=20=C3=91=20=C3=91=20
TEL;CELL;PREF:123456
TEL;WORK:123456
TEL;WORK;FAX:123456
EMAIL;PREF;WORK:[email protected]
EMAIL;PREF;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91=C3=91=C3=91
ORG;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91
ORG;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91
URL:www.company.com
URL:http://www.company.com
PHOTO;ENCODING=BASE64;JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAIBAQEBAQIBAQE
CAgICAgQDAgICAgUEBAMEBgUGBgYFBgYGBwkIBgcJBwYGCAsICQoKCgoKBggLDAsKDAkKCgr/
2wBDAQICAgICAgUDAwUKBwYHCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKC
b2wxBqZRuiZQ8z9VmhGMY/wA803yB/dr4Z8a/tiftAaX8DPhLYzeM9U0a98WeCtH0tvFv9mW8
39ra1qWnWcseqSLIirBYWkn2gXc8TP5L3EI8qTcNnrOp/tsa7P8AseD9prxf8N7zwesGtiwuN
Ni1D7Tv/eFVmhuJoofOjO5VJKrh1dRu2ZM+zZKg2fO+g/8ABEP45eC/Dmn+HPBX7ZWhab/Zt2
9xAyfDS2kWF3WRWeNXlPlsVkKkrjhn5+Yg/Xn7HPwP+PHwC+GV74L/AGiv2kLn4natc6kLm21
u6077L9ni8qNPJEe9wVDozgZwN5AA6n1ouMnJpkpDPk9cD+ZqnVqTiqUn6GtStiffUqnNzO+/
/B8zlvG/wN+D3xF+IXhL4u+O/AdnqfibwFNdS+D9WuVYyaW1yiRzmIbsDesaA5B+6OlX/EXg3
wH4t1fT/EPizwRDqF7pyXENhczMzNCk8kbyqPRWaCIsvIOxfSttSxIKjpSPdvEyBEyd+1/xP/
1qcXpyvdC5tDmdH+CHwg8K/EGb4w6B8PtKs/E95pkljJrUK4nFqxhJhz0K/wCj2/H/AExX0pu
jfBH4TaR8ZtX/AGgvDfw7tbXxd4g09LPVvXXKt9qnhQQqI3OcY220Axxwi/jxv7S/7eH7Kn7H
R0lP2g/iBeaJJrOuRaVYNa6PcXXm3MymRYz5UbhV2jJY4xxz1r0fwR498K+PtGbXfCGr/a7ZJ
9j7w3CvijVraDxdbyWg/4lFlOVN2WP8AZ7GUhnJKv5Ifacule3Qz+QTKD3HanDUELEk9T6UKS
juVGVjxGe31HUdS1/4da/8Ask6APDvgrV4W8GzHT1mi1DzhPLK8UL2IS1O9V3PE8py+Tgnnn/
AmoQfD/wAeab4J8F/8E3tC0qz1LTJJ9V1/RdO+zR2p8yTFviLTVWUsYYGO50+/nB2Lv+jnuoZ
l2S9ByOKYLrys+SP0p+0iPnP/2Q==
END:VCARD
+24
View File
@@ -0,0 +1,24 @@
BEGIN:VCARD
VERSION:2.1
UID:urn:uuid:3fe8d47e-cc86-59da-a2b1-065c17b83016
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=C3=91=C3=91=C3=91;;;;
FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=C3=91=C3=91=C3=91
TEL;CELL;PREF:55556666
EMAIL;PREF:[email protected]
ORG;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
ORG;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=80
ORG;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=C3=91=
CATEGORIES:My Contacts
END:VCARD
+11
View File
@@ -0,0 +1,11 @@
BEGIN:VCARD
VERSION:2.1
UID:urn:uuid:2f122946-71f4-5ab8-8529-6f5fe0b51e61
FN:John Doe
N:Doe;john;;;
ORG:Acme Solutions
TEL;TYPE=CELL:+96123456789
PHOTO;ENCODING=BASE64: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
NOTE:
END:VCARD
+42
View File
@@ -0,0 +1,42 @@
BEGIN:VCARD
VERSION:3.0
X-COUCHDB-APPLICATION-ANNOTATIONS:{"Evolution":{"revision":"2012-03-05T13:3
2:54Z"}}
X-AIM;TYPE=HOME;X-COUCHDB-UUID="cb9e11fc-bb97-4222-9cd8-99820c1de454":johnn
[email protected]
URL;X-COUCHDB-UUID="0abc9b8d-0845-47d0-9a91-3db5bb74620d":http://www.ibm.co
m
TEL;X-COUCHDB-UUID="c2fa1caa-2926-4087-8971-609cfc7354ce";TYPE=CELL:905-666
-1234
TEL;X-COUCHDB-UUID="fbfb2722-4fd8-4dbf-9abd-eeb24072fd8e";TYPE=WORK,VOICE:9
05-555-1234
UID:477343c8e6bf375a9bac1f96a5000837
N:Doe;John;Richter\, James;Mr.;Sr.
X-EVOLUTION-FILE-AS:Doe\, John
FN:Mr. John Richter\, James Doe Sr.
NICKNAME:Johny
X-EVOLUTION-SPOUSE:Maria
ORG:IBM;Accounting;Dungeon
TITLE:Money Counter
X-EVOLUTION-MANAGER:Big Blue
X-EVOLUTION-ASSISTANT:Little Red
CATEGORIES:VIP
NOTE:THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "A
S IS" AND ANY EXPRESS OR IMPLIED WARRANTIES\, INCLUDING\, BUT NOT LIMITED
TO\, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULA
R PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRI
BUTORS BE LIABLE FOR ANY DIRECT\, INDIRECT\, INCIDENTAL\, SPECIAL\, EXEMPL
ARY\, OR CONSEQUENTIAL DAMAGES (INCLUDING\, BUT NOT LIMITED TO\, PROCUREME
NT OF SUBSTITUTE GOODS OR SERVICES\; LOSS OF USE\, DATA\, OR PROFITS\; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY\, WHE
THER IN CONTRACT\, STRICT LIABILITY\, OR TORT (INCLUDING NEGLIGENCE OR OTH
ERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE\, EVEN IF ADVIS
ED OF THE POSSIBILITY OF SUCH DAMAGE.
EMAIL;TYPE=WORK;X-COUCHDB-UUID="83a75a5d-2777-45aa-bab5-76a4bd972490":john.
[email protected]
ADR;TYPE=HOME:ASB-123;;15 Crescent moon drive;Albaney;New York;12345;United
States of America
BDAY:1980-03-22
X-EVOLUTION-ANNIVERSARY:1980-03-22
REV:2012-03-05T13:32:54Z
END:VCARD
+178
View File
@@ -0,0 +1,178 @@
BEGIN:VCARD
VERSION:3.0
PRODID:-//Apple Inc.//Address Book 6.1//EN
N:Doe;John;Johny;Mr.;I
FN:Mr. Doe John I Johny
NICKNAME:Johny\,JayJay
ORG:IBM;SUN
TITLE:Generic Accountant
EMAIL;type=INTERNET;type=WORK;type=pref:[email protected]
EMAIL;type=INTERNET;type=WORK:[email protected]
TEL;type=CELL;type=VOICE;type=pref:+1 (212) 204-34456
TEL;type=WORK;type=FAX:00-1-212-555-7777
item1.ADR;type=HOME;type=pref:;;25334\nSouth cresent drive\, Building 5\, 3rd floo r;New York;New York;NYC887;U.S.A.
NOTE:THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"\nAND ANY EXPRESS OR IMPLIED WARRANTIES\, INCLUDING\, BUT NOT LIMITED TO \, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR P URPOSE\nARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTOR S BE\nLIABLE FOR ANY DIRECT\, INDIRECT\, INCIDENTAL\, SPECIAL\, EXEMPLARY\, OR\nCONSEQUENTIAL DAMAGES (INCLUDING\, BUT NOT LIMITED TO\, PROCUREMENT OF\n SUBSTITUTE GOODS OR SERVICES\; LOSS OF USE\, DATA\, OR PROFITS\; OR BUSINESS \nINTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY\, WHETHER IN\n CONTRACT\, STRICT LIABILITY\, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\nA RISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE\, EVEN IF ADVISED OF THE\n POSSIBILITY OF SUCH DAMAGE.
item2.URL;type=pref:http://www.sun.com
item2.X-ABLabel:_$!<HomePage>!$_
BDAY;value=date:1980-05-21
PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD/4QBARXhpZgAATU0AKgAA
AAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAyKADAAQAAAABAAAAyAAAAAD/2wBDAA
IBAQIBAQICAQICAgICAwUDAwMDAwYEBAMFBwYHBwcGBgYHCAsJBwgKCAYGCQ0JCgsLDAwMBwkN
Dg0MDgsMDAv/2wBDAQICAgMCAwUDAwULCAYICwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCw
sLCwsLCwsLCwsLCwsLCwsLCwsLCwv/wAARCADIAMgDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEA
AAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhBy
JxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZ
WmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxM
XGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAA
AAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFE
KRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVm
Z2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyM
nK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD9/KKKKAIN59T+dG8+
p/OkorkuzMXefU/nRvPqfzpKKLsBd59T+dG8+p/OkqC+u/skZYkDAzyaLsCUyNk/MfzrkPihq9
3p9s5sLq4gIjQ5jkK878djWd4p+L8Wg3TI91aJgnhp0XufUe1eY/FX9oSCWCQfbrD/AFaj/j5j
/v8A0q0xn5C/8Fm/20vjH8LvGjRfDP4s/Ezw7Fx8ml+KL6zX/V2Z6RSju7/99H1NflT4i/4Ktf
tRwaxMsP7Sfx9RRtwF+IOrgD5R/wBPNfcv/BajxwniLxozLNC/T7sit/yzs/T6V+TviPD6zMcj
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
UID:0e7602cc-443e-4b82-b4b1-90f62f99a199
X-ABUID:0E7602CC-443E-4B82-B4B1-90F62F99A199:ABPerson
GEO:-2.600000;3.400000
CLASS:Public
PROFILE:VCard
TZ:1:00
LABEL;TYPE=HOME,PARCEL,PREF:John Doe\nNew York\, NewYork\,\nSouth Crecent Dr
ive\,\nBuilding 5\, floor 3\,\nUSA
SORT-STRING:JOHN
ROLE:Counting Money
X-GENERATOR:Cardme Generator
SOURCE:Whatever
MAILER:Mozilla Thunderbird
NAME:VCard for John Doe
X-LONG-STRING:12345678901234567890123456789012345678901234567890123456789012
34567890123456789012345678901234567890
END:VCARD
+353
View File
@@ -0,0 +1,353 @@
BEGIN:VCARD
VERSION:3.0
UID:urn:uuid:ddc3671b-3ed4-594c-b23e-fc37bd245a7c
N:Doe;John;Richter\,James;Mr.;Sr.
FN:Mr. John Richter\,James Doe Sr.
NICKNAME:Johny
X-PHONETIC-FIRST-NAME:Jon
X-PHONETIC-LAST-NAME:Dow
ORG:IBM;Accounting
TITLE:Money Counter
EMAIL;type=INTERNET;type=WORK;type=pref:[email protected]
TEL;type=WORK;type=pref:905-777-1234
TEL;type=HOME:905-666-1234
TEL;type=CELL:905-555-1234
TEL;type=HOME;type=FAX:905-888-1234
TEL;type=WORK;type=FAX:905-999-1234
TEL;type=PAGER:905-111-1234
item1.TEL:905-222-1234
item1.X-ABLabel:AssistantPhone
item2.ADR;type=HOME;type=pref:;;Silicon Alley 5\,;New York;New York;12345;United States of America
item2.X-ABADR:Silicon Alley
item3.ADR;type=WORK:;;Street4\nBuilding 6\nFloor 8;New York;;12345;USA
item3.X-ABADR:Street 4, Building 6,\nFloor 8\nNew York\nUSA
NOTE:THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES\, INCLUDING\, BUT NOT LIMITED TO\, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT\, INDIRECT\, INCIDENTAL\, SPECIAL\, EXEMPLARY\, OR CONSEQUENTIAL DAMAGES (INCLUDING\, BUT NOT LIMITED TO\, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES\; LOSS OF USE\, DATA\, OR PROFITS\; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY\, WHETHER IN CONTRACT\, STRICT LIABILITY\, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE\, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\nFavotire Color\: Blue
item4.URL;type=pref:http\://www.ibm.com
item4.X-ABLabel:_$!<HomePage>!$_
BDAY;value=date:2012-06-06
PHOTO;BASE64:
/9j/4AAQSkZJRgABAQAAAQABAAD/4QBARXhpZgAATU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAA
AqACAAQAAAABAAABAKADAAQAAAABAAABAAAAAAD/2wBDAAIBAQIBAQICAQICAgICAwUDAwMDAwYE
BAMFBwYHBwcGBgYHCAsJBwgKCAYGCQ0JCgsLDAwMBwkNDg0MDgsMDAv/2wBDAQICAgMCAwUDAwUL
CAYICwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwv/wAAR
CAEAAQADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgED
AwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRol
JicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWW
l5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3
+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3
AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5
OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaan
qKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIR
AxEAPwD9/KKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKK
KACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoooo
AKKKKACiiigAooooAKKKKACiiigAooooAKKK8t/bU/aq0X9iX9l/xf8AE/x5bzXth4WtBMtpC22S
9nd1ihhDEHbvlkjUtg7QScHGKaTk0kJtRV2epUV/PP4k/wCDrP8AaGv9buZfDPhn4W6dYPITBbya
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
/9k=
item5.X-ABRELATEDNAMES;type=pref:Jenny
item5.X-ABLabel:Spouse
X-ABUID:6B29A774-D124-4822-B8D0-2780EC117F60\:ABPerson
END:VCARD
+47
View File
@@ -0,0 +1,47 @@
BEGIN:VCARD
VERSION:2.1
UID:urn:uuid:a04eb0c4-ef5a-538d-970b-c4b204cf8d2d
N;LANGUAGE=en-us:Doe;John;Richter,James;Mr.;Sr.
FN:Mr. John Richter James Doe Sr.
NICKNAME:Johny
ORG:IBM;Accounting
TITLE:Money Counter
NOTE:THIS SOFTWARE IS PROVIDED BY GEORGE EL-HADDAD ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GEORGE EL-HADDAD OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
TEL;WORK;VOICE:(905) 555-1234
TEL;HOME;VOICE:(905) 666-1234
ADR;WORK;PREF:;;Cresent moon drive;Albaney;New York;12345;United States of America
LABEL;WORK;PREF;ENCODING=QUOTED-PRINTABLE:Cresent moon drive=0D=0A=
Albaney, New York 12345
ADR;HOME:;;Silicon Alley 5,;New York;New York;12345;United States of America
LABEL;HOME;ENCODING=QUOTED-PRINTABLE:Silicon Alley 5,=0D=0A=
New York, New York 12345
X-MS-OL-DEFAULT-POSTAL-ADDRESS:2
URL;WORK:http://www.ibm.com
ROLE:Counting Money
BDAY:19800322
X-MS-ANNIVERSARY:20110113
EMAIL;PREF;INTERNET:[email protected]
X-MS-IMADDRESS:[email protected]
PHOTO;TYPE=JPEG;ENCODING=BASE64:
/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQY
GBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYa
KCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAAR
CAAaACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAA
AgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK
FhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG
h4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl
5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREA
AgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk
NOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOE
hYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk
5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDzS20gtj5a1R4feMqJYnQlQwDAjII4Irub
PQTx8ldNYacfs6213F59sM7R0eL1Kn+nT+dZyk1sQ2zyUaBkfc/SpB4ZOPuc/Su61vSo5tTf
SrZySEDKWQgyFgSowSBgEDdz7Z71S8ITNB4luPD2szQvdvM4t/KYsoGNwUNgZwAecCpVWLfK
NO56ba2sSqpIGD0Na1vDBxwKwYifU1dhJx1NJq5m1cw/Enw9Gt6ydRbXLmNljMcUW0FYgSCc
YI9KXwX4EXwz4hm1STUhdM8RiCtGM4JByWz7DpXQ72/vH86idm/vH86ShZ3uNXtY/9k=
X-MS-OL-DESIGN;CHARSET=utf-8:<card xmlns="http://schemas.microsoft.com/office/outlook/12/electronicbusinesscards" ver="1.0" layout="left" bgcolor="ffffff"><img xmlns="" align="tleft" area="32" use="photo"/><fld xmlns="" prop="name" align="left" dir="ltr" style="b" color="000000" size="10"/><fld xmlns="" prop="org" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="title" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="dept" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="telwork" align="left" dir="ltr" color="000000" size="8"><label align="right" color="626262">Work</label></fld><fld xmlns="" prop="telhome" align="left" dir="ltr" color="000000" size="8"><label align="right" color="626262">Home</label></fld><fld xmlns="" prop="email" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="addrwork" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="addrhome" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="webwork" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="blank" size="8"/><fld xmlns="" prop="blank" size="8"/><fld xmlns="" prop="blank" size="8"/><fld xmlns="" prop="blank" size="8"/><fld xmlns="" prop="blank" size="8"/><fld xmlns="" prop="blank" size="8"/></card>
X-MS-MANAGER:Big Blue
X-MS-ASSISTANT:Jenny
REV:20120305T131933Z
END:VCARD
+80
View File
@@ -0,0 +1,80 @@
BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:44e43a63-2b48-575a-a5d7-933fc3eb2a9e
N:LastName;FirstName;MiddleName;Prefix;Suffix
FN:Prefix FirstName MiddleName LastName Suffix
TEL;TYPE=home,voice:555-555-1111
TEL;TYPE=work,voice:555-555-1112
TEL;TYPE=cell,voice:555-555-1113
TEL;TYPE=cell,voice:555-555-1114
TEL;TYPE=voice:555-555-1115
TEL;TYPE=home,fax:555-555-1116
TEL;TYPE=work,fax:555-555-1117
TEL;TYPE=voice:555-555-1118
TEL;TYPE=voice:555-555-1119
EMAIL;TYPE=home:[email protected]
EMAIL;TYPE=work:[email protected]
EMAIL;TYPE=school:[email protected]
EMAIL;TYPE=other:[email protected]
EMAIL;TYPE=customtype:[email protected]
PHOTO:https://d3m0kzytmr41b1.cloudfront.net/c335e945d1b60edd9d75eb4837c432f
637e95c8a
PHOTO:https://d3m0kzytmr41b1.cloudfront.net/c335e945d1b60edd9d75eb4837c432f
637e95c8a
PHOTO:https://d2ojpxxtu63wzl.cloudfront.net/static/aa915d1f29f19baf560e5491
decdd30a_67c95da9133249fde8b0da7ceebc298bf680117e6f52054f7f5f7a95e8377238
ORG:Organization1;Department1
ORG:Organization2;Department2
TITLE:Title1
TITLE:Title2
BDAY;ALTID=1:20160801
BDAY;ALTID=1;VALUE=text:2016-08-01
GENDER:M
X-GENDER:male
X-ID:14f9aba0c9422da9ae376fe28bd89c2a.0
X-ETAG:fffffea9056d8166e2b7a427977e570c87dd51279d11d9b137c593eb
X-FC-TAGS:579c773f-736d-11e6-8dff-0ac8448704fb
X-FC-LIST-ID:8ad23200aa3e1984736b11e688dc0add41994b95
X-FCENCODED-582D46432D52656C617465644E616D65733A4D6F74686572:Mother
X-FCENCODED-582D46432D52656C617465644E616D65733A466174686572:Father
X-FCENCODED-582D46432D52656C617465644E616D65733A506172656E74:Parent
X-FCENCODED-582D46432D52656C617465644E616D65733A42726F74686572:Brother
X-FCENCODED-582D46432D52656C617465644E616D65733A536973746572:Sister
X-FCENCODED-582D46432D52656C617465644E616D65733A4368696C64:Child
X-FCENCODED-582D46432D52656C617465644E616D65733A467269656E64:Friend
X-FCENCODED-582D46432D52656C617465644E616D65733A53706F757365:Spouse
X-FCENCODED-582D46432D52656C617465644E616D65733A4669616E63C3A9:Fiance
X-FCENCODED-582D46432D52656C617465644E616D65733A506172746E6572:Partner
X-FCENCODED-582D46432D52656C617465644E616D65733A417373697374616E74:Assistan
t
X-FCENCODED-582D46432D52656C617465644E616D65733A4D616E61676572:Manager
X-FCENCODED-582D46432D52656C617465644E616D65733A4F74686572:Other
X-FCENCODED-582D46432D52656C617465644E616D65733A437573746F6D54595045:Custom
X-FCENCODED-582D46432D4F7468657244617465733A416E6E6976657273617279:2016-08-
02
X-FCENCODED-582D46432D4F7468657244617465733A4F74686572:2016-08-03
X-FCENCODED-582D46432D4F7468657244617465733A437573746F6D54595045:2016-08-04
NOTE:Notes line 1\nNotes line 2
URL:http://www.homepage.com
URL:http://www.blog.com
URL:http://www.other.com
URL:http://www.custom.com
ADR;TYPE=home:;HomeExtended;HomeStreet;HomeCity;HomeState;HomePostal;HomeCo
untry
ADR;TYPE=work:;WorkExtended;WorkStreet;WorkCity;WorkState;WorkPostal;WorkCo
untry
ADR;TYPE=other:;OtherExtended;OtherStreet;OtherCity;OtherState;OtherPostal;
OtherCountry
ADR;TYPE=customtype:;CustomExtended;CustomStreet;CustomCity;CustomState;Cus
tomPostal;CustomCountry
NICKNAME:NickName
IMPP;X-SERVICE-TYPE=GTalk:xmpp:gtalk
IMPP;X-SERVICE-TYPE=Skype:skype:skype
IMPP;X-SERVICE-TYPE=Yahoo:ymsgr:yahoo
IMPP;X-SERVICE-TYPE=AIM:aim:aim
IMPP;X-SERVICE-TYPE=Jabber:xmpp:jabber
IMPP;X-SERVICE-TYPE=Other:other:other
IMPP;X-SERVICE-TYPE=CustomTYPE:customtype:custom
CATEGORIES:Tag
PRODID:ez-vcard 0.9.14-fc
END:VCARD
+92
View File
@@ -0,0 +1,92 @@
BEGIN:VCARD
VERSION:3.0
UID:urn:uuid:887ee0fa-fccf-520e-af1a-ec028982989c
FN:VCard Test
N:Test;VCard;;;
NICKNAME:TheNickname
X-PHONETIC-FIRST-NAME:ThePhoneticFirstName
X-PHONETIC-LAST-NAME:ThePhoneticLastName
EMAIL;TYPE=INTERNET:[email protected]
EMAIL;TYPE=INTERNET;TYPE=HOME:[email protected]
EMAIL;TYPE=INTERNET;TYPE=WORK:[email protected]
EMAIL;TYPE=INTERNET:[email protected]
item1.EMAIL;TYPE=INTERNET:[email protected]
item1.X-ABLabel:CustomEmailCategory
X-GTALK:IM2
X-AIM:IM3
X-YAHOO:IM4
X-SKYPE:IM5
X-QQ:IM6
X-MSN:IM7
X-ICQ:IM8
X-JABBER:IM9
TEL:5555551111
TEL;TYPE=HOME:5555551112
TEL;TYPE=WORK:5555551113
TEL:5555551114
TEL;TYPE=CELL:5555551115
TEL;TYPE=MAIN:5555551116
TEL;TYPE=HOME;TYPE=FAX:5555551117
TEL;TYPE=WORK;TYPE=FAX:5555551118
item2.TEL:5555551119
item2.X-ABLabel:GRAND_CENTRAL
TEL;TYPE=PAGER:5555551120
item3.TEL:5555551121
item3.X-ABLabel:CustomePhoneCategory
ADR:;;111 Main St;NY;New York;10011;
ADR;TYPE=HOME:;;112 Main St;NY;New York;10011;
ADR;TYPE=WORK:;;113 Main St;NY;New York;10011;
ADR:;;114 Main St;NY;New York;10011;
item4.ADR:;;115 Main St;NY;New York;10011;
item4.X-ABLabel:CustomAddressCategory
ORG:TheCompany
TITLE:TheJobTitle
BDAY:1912-06-23
URL:http\://www.example1.com
item5.URL:http\://www.example2.com
item5.X-ABLabel:PROFILE
item6.URL:http\://www.example3.com
item6.X-ABLabel:BLOG
item7.URL:http\://www.example4.com
item7.X-ABLabel:_$!<HomePage>!$_
URL;TYPE=WORK:http\://www.example5.com
item8.URL:http\://www.example6.com
item8.X-ABLabel:CustomWebsiteCategory
item9.X-ABDATE:1930-03-20
item9.X-ABLabel:_$!<Anniversary>!$_
X-ABDATE:1776-07-04
item10.X-ABDATE:2000-09-12
item10.X-ABLabel:CustomDateCategory
X-ABRELATEDNAMES:Name1
item11.X-ABRELATEDNAMES:Name2
item11.X-ABLabel:_$!<Spouse>!$_
item12.X-ABRELATEDNAMES:Name3
item12.X-ABLabel:_$!<Child>!$_
item13.X-ABRELATEDNAMES:Name4
item13.X-ABLabel:_$!<Mother>!$_
item14.X-ABRELATEDNAMES:Name5
item14.X-ABLabel:_$!<Father>!$_
item15.X-ABRELATEDNAMES:Name6
item15.X-ABLabel:_$!<Parent>!$_
item16.X-ABRELATEDNAMES:Name7
item16.X-ABLabel:_$!<Brother>!$_
item17.X-ABRELATEDNAMES:Name8
item17.X-ABLabel:_$!<Sister>!$_
item18.X-ABRELATEDNAMES:Name9
item18.X-ABLabel:_$!<Friend>!$_
item19.X-ABRELATEDNAMES:Name10
item19.X-ABLabel:RELATIVE
item20.X-ABRELATEDNAMES:Name11
item20.X-ABLabel:_$!<Manager>!$_
item21.X-ABRELATEDNAMES:Name12
item21.X-ABLabel:_$!<Assistant>!$_
item22.X-ABRELATEDNAMES:Name13
item22.X-ABLabel:REFERRED_BY
item23.X-ABRELATEDNAMES:Name14
item23.X-ABLabel:_$!<Partner>!$_
item24.X-ABRELATEDNAMES:Name15
item24.X-ABLabel:DOMESTIC_PARTNER
item25.X-ABRELATEDNAMES:Name16
item25.X-ABLabel:CustomRelationCategory
NOTE:note line 1\nnote line 2\nCustomField: field value
END:VCARD
+94
View File
@@ -0,0 +1,94 @@
BEGIN:VCARD
VERSION:2.1
UID:urn:uuid:e5421786-42af-5192-b326-011c7e68d2e7
N;LANGUAGE=en-us:Angstadt;Michael;;Mr.;Jr.
FN:Mr. Michael Angstadt Jr.
NICKNAME:Mike
ORG:TheCompany;TheDepartment
TITLE:TheJobTitle
NOTE;CHARSET=us-ascii;ENCODING=QUOTED-PRINTABLE:This is the NOTE field =0D=0A=
I assume it encodes this text inside a NOTE vCard type.=0D=0A=
But I'm not sure because there's text formatting going on here.=0D=0A=
It does not preserve the formatting
TEL;WORK;VOICE:(111) 555-1111
TEL;HOME;VOICE:(111) 555-2222
TEL;CELL;VOICE:(111) 555-4444
TEL;WORK;FAX:(111) 555-3333
X-MS-TEL;VOICE;CALLBACK:(111) 555-4444
ADR;WORK;PREF:;TheOffice;222 Broadway;New York;NY;99999;USA
LABEL;WORK;PREF;ENCODING=QUOTED-PRINTABLE:222 Broadway=0D=0A=
New York, NY 99999=0D=0A=
USA
X-MS-OL-DEFAULT-POSTAL-ADDRESS:2
URL;HOME:http://mikeangstadt.name
URL;WORK:http://mikeangstadt.name
ROLE:TheProfession
BDAY:19220310
X-MS-ANNIVERSARY:20120801
KEY;X509;ENCODING=BASE64:
MIIB/jCCAWugAwIBAgIQDdkWkvA2cqtGkw2P4zAoZDAJBgUrDgMCHQUAMBMxETAPBgNVBAMT
CG1hbmdzdGFkMCAXDTExMDYxNzIwMTgwM1oYDzIxMTEwNTI0MjAxODAzWjATMREwDwYDVQQD
EwhtYW5nc3RhZDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAmfYuhkT693IXf/iSESbw
9cmeM3p2AWDC8oGDiCDtenNIGdITa8e/kwx9JIFl7a02cMbHtG6BmbGBINzzgepsY6lE+Pcx
ns4FVpgWr1hn3YVg2LKqgRkKLp+ddqL+LWCg3qvcQxnZJoooRLhpurr/T9TaE83nAr6aqoSt
jYh+KtECAwEAAaNZMFcwFQYDVR0lBA4wDAYKKwYBBAGCNwoDBDAzBgNVHREELDAqoCgGCisG
AQQBgjcUAgOgGgwYbWFuZ3N0YWRAcGVubm11dHVhbC5jb20AMAkGA1UdEwQCMAAwCQYFKw4D
Ah0FAAOBgQA9/txKYVIj6/UoDRYAp23NoRjaeZ0cTqcpKtxlUi8z7TtczAROgTr4Pi+VaKR0
Y+RzgVxPN8wvNoVogCWLTpVxhoe8R5geoS4L1w1O1HYwu2KlG57SOR4R/Lv5bgi6CNTlS1N4
zIDBOSyypTmzxC+JPmyn/8vZleIz8CYnwmfBEg==
EMAIL;PREF;INTERNET:[email protected]
X-MS-IMADDRESS:[email protected]
PHOTO;TYPE=JPEG;ENCODING=BASE64:
/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQY
GBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYa
KCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAAR
CABWAEgDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAA
AgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK
FhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG
h4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl
5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREA
AgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk
NOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOE
hYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk
5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD6pooqlrOpQaTplxe3TBYolLH3PYUAQeIN
e0/QLM3Op3CxJztXqzH0Arx3xN8Zb5ndNEtBDHztlkj3tx04yB/OuM8Y6zqHiXU3ubhyImbC
J6JnGB6cE/lXJT2rbJHaZiioSrZPIBxk/oB7UhXOsvPir4ofJbU2jyeP3axgZHqM96NP+NXi
awZGmuIruI9FnjHzDnJyMfTr2rzq7WdI2wR5YVcg+5yBWTPIjxsI8K27lD0b5hhR/ntSBH1h
4H+NWg6+Y7fUz/Zt43A8w/u3Ps3b8a9UVg6hlIKkZBHevzp80oeCyyLwc9f/ANXFfS37OnxF
lv2Hh3V53klC/wCjO5zjH8Oadyj6BooopiCvK/jXqjbbTTI2wp/eyDHX0r1SvAvHkp1/4hG0
hkGwN5e7HQL1xUydlcaTk7I4o2tzds0dnA0rnPygdeO3tk/yqC/8L6skC5s5zBgAsyc4A546
5J56ete22NjDp9msVpGqKPTq3uT3qpqjMbYk4/GuZ130R2wwcerPm/Ura4RvKu0MaZ8xw4OC
ewAI5rmNSbICP8owWbjJyc8V7l4kMLRSJLGHX35ryDxHbRRXDeQDtLbwg7npjNXTrc+jM6+F
9lqndHOM2X8lyBKn3HyMZ5PPrWr4Y1ObR9atL62ZlmgcMoHqOucVjSKZJJA7DzAeGHdj1/Kp
reTcVckZJ2tx39a2OdH6FeGdUTW9AsNSi4W5hWTHoSOlFcH+zrqDX3w3tY3JJtpGiGfTqP0N
FNEs9G1CcWthcTkgeXGzZPsK+TPtc+o+I7q7F41p5KtNNOoyRk19TeK4TP4a1OIAsWt3AAOM
8V84/B23S+vNaaYCVflRtwzz1rGvOysdOGp80r9iz4E1u/vtbeFNTury0U4Pnw7cfjTfir4g
udPnj06znKTufvDtXfSWVrpQadUVMAscKB/KvINVjj17X5p5XyfM+U1yc2p6Sg+U4+aa/eDc
/iKLzmBIidTyPr0rLjW4uInjumBlzlHXoa9dvtGt722WN4oYpVTZkRDp6Vx17oUGlqQpJ54F
XGomZTotbnnEhJlZnG2VXKsvp9KjdWS6YEYV1zx6itTXjFb6tGUPMq4YD17Gqd0jEcFTIgDj
aMgV1xd1c82ceWVj6q/ZWvDN4T1G2OB5VwGA9mUf4UVl/spQSeTrNwM+QyxIAePm+Y9PoRRV
rYhnumvME0W/YjIED8fga+U/hh4nttA1W5jvTstbnqcdGHevrHVE36bdr6xMP0NfBmqyG3nO
5mVzJtIC8nk1zYhNtHdg2knc+g/GPjLRYdInkWdLhimUVDkn8K8CsvEbnW1uLaGZWaUHyivB
Gaz55jLIBCHlRDmRxk7eOO3FZ812sLh4pBCeTgZLD0/kaxgu51ylbbY+lNUlgXT0nUAbkz9K
8c8Ta8vny/NnbnAri7vxJqw06ANf3DIzHhnOMVm3FwTqPlyOxjde/XPvVQptasipWTVkSKzX
DXF1cN+9dsIPat6ztlXT4Ccl8nfk5OD0rmdPR5NqLkoGJGfSvafhR8M9V8YtDcTh7fRww3zN
xuA6hfU9q6I7nBVd0j3H9m3THsPh1HPIuDdzNKOP4R8o/lRXpmmWMGm6fb2Vmgjt4EEaKOwF
Fao5ywyhlKsMgjBFfFXxd8NT6J4vuYpo2gjkuDNGV6MpJIwfSvs37V+7d9nCvsrjfix4Mt/G
WhNHEY01G3+aGQ/+gn2NZ1YcyuuhvQqckrPZnx9pGt32kia4sJ1jEpCyBD1xkc5Hb15qjr2s
3FyIZbqKAzOx5ZRxjqff/wCtVjxHot5pNpc6dcwNBNHk8qQTg8/596w9ahVoE2BR5JyyA56/
5Nc0Y3Z6E5tIg1Zla4tosRgL8xUdvas2QA3UzZLbVwKluiGuY5UUsWHAHOK6Lw7oB8z7RfYA
J3bD3HvWq91HO/ekeofs8eAtK8RXWNajlaPy/ORAQNwVhwfY9/pX15bQRW0EcFvGkUMahVRB
gKB2FfNHwf1iDSPFtm07JFbSKYSTwFyOPwzivpW2ure6TfbTxTL6xuGH6VdF3RhiI2kiaiii
tjnKT27CZhuHkk7yO+aoi4Ky7VXJZWGWPeiikwMPxPp8F/Z2r3ltbyuj4yyA8Y6dK8q8WeBt
LmilNva28A9FX8aKK5anxHbQfuHkV/oiaddOgKlQeMCkB5CrwDRRSZrHc2LNWYRxocMxCgmu
00nUbzQZ4nsbmRHgQkkH7x9xRRVUu5liHsjv9L+LksMSLq2niUjhpIG2k++Dx/Kiiit7s5Gj
/9k=
X-MS-OL-DESIGN;CHARSET=utf-8:<card xmlns="http://schemas.microsoft.com/office/outlook/12/electronicbusinesscards" ver="1.0" layout="left" bgcolor="ffffff"><img xmlns="" align="tleft" area="32" use="photo"/><fld xmlns="" prop="name" align="left" dir="ltr" style="b" color="000000" size="10"/><fld xmlns="" prop="org" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="title" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="dept" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="telwork" align="left" dir="ltr" color="000000" size="8"><label align="right" color="626262">Work</label></fld><fld xmlns="" prop="telcell" align="left" dir="ltr" color="000000" size="8"><label align="right" color="626262">Mobile</label></fld><fld xmlns="" prop="telhome" align="left" dir="ltr" color="000000" size="8"><label align="right" color="626262">Home</label></fld><fld xmlns="" prop="email" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="addrwork" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="webwork" align="left" dir="ltr" color="000000" size="8"/><fld xmlns="" prop="blank" size="8"/><fld xmlns="" prop="blank" size="8"/><fld xmlns="" prop="blank" size="8"/><fld xmlns="" prop="blank" size="8"/><fld xmlns="" prop="blank" size="8"/><fld xmlns="" prop="blank" size="8"/></card>
X-MS-MANAGER:TheManagerName
X-MS-ASSISTANT:TheAssistantName
X-MS-SPOUSE:TheSpouse
FBURL:http://website.com/mycal
REV:20120801T184631Z
END:VCARD
+204
View File
@@ -0,0 +1,204 @@
BEGIN:VCARD
VERSION:3.0
UID:urn:uuid:8c11cf62-42b4-5513-9d35-1318e6cb0540
N;CHARSET=UTF-8:Doe;John
FN;CHARSET=UTF-8:John Doe
ORG;CHARSET=UTF-8:TheOrganization;TheDepartment
NICKNAME;CHARSET=UTF-8:Johnny
ADR;TYPE=WORK,POSTAL;CHARSET=UTF-8:;222 Broadway;Suite 100;New York;NY;98765;USA
ADR;TYPE=HOME,POSTAL;CHARSET=UTF-8:;123 Main St;Apt 10;Austin;TX;12345;USA
TEL;TYPE=WORK,VOICE:555-555-1111
TEL;TYPE=HOME,VOICE:555-555-2222
TEL;TYPE=CELL,VOICE:555-555-5555
TEL;TYPE=FAX:555-555-3333
TEL;TYPE=PAGER:555-555-4444
EMAIL;TYPE=PREF,INTERNET:[email protected]
EMAIL;TYPE=INTERNET:[email protected]
EMAIL;TYPE=INTERNET:[email protected]
EMAIL;TYPE=INTERNET:[email protected]
EMAIL;TYPE=INTERNET:[email protected]
URL;TYPE=HOME:http://www.private-webpage.com
TITLE;CHARSET=UTF-8:TheTitle
URL;TYPE=WORK:http://www.work-webpage.com
CATEGORIES;CHARSET=UTF-8:category1\, category2\, category3
X-SPOUSE:TheSpouse
BDAY:1970-09-21
X-ANNIVERSARY:1990-04-30
NOTE;CHARSET=UTF-8:This is the notes field.\nSecond Line\n\nFourth Line\nYou can put anything in the "note" field\; even curse words.
PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQEAYABgAAD/4QBoRXhpZgAASUk
qAAgAAAAEABoBBQABAAAAPgAAABsBBQABAAAARgAAACgBAwABAAAAAgAAADEBAgASAAA
ATgAAAAAAAABgAAAAAQAAAGAAAAABAAAAUGFpbnQuTkVUIHYzLjUuMTAA/9sAQwACAQE
BAQECAQEBAgICAgIEAwICAgIFBAQDBAYFBgYGBQYGBgcJCAYHCQcGBggLCAkKCgoKCgY
ICwwLCgwJCgoK/9sAQwECAgICAgIFAwMFCgcGBwoKCgoKCgoKCgoKCgoKCgoKCgoKCgo
KCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoK/8AAEQgAhABkAwEiAAIRAQMRAf/EAB8
AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQE
CAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU
2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpe
YmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fL
z9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQ
EAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDT
hJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoK
DhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19j
Z2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+PNL8D3+jRfZbPxprEUiE/v
EmBHX0AH8605Lj4hWURGlfEO7LjAT7TDvGff5jXvP7I37F+v/ALXN5r/9ieNoNIGjPDv
8+xMol80PjG1htxs9+orA/ak/Zn8Tfss/Ea3+H3ijX7LUnubKK8huLGJ1XYzsoBDDIOU
J79a/K/ZYqVL2zXu/I+yWNTq+z5te1jjfgH4x8T+NvDd/f+Jb7z5INSaCNhGq4UIp/hA
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
END:VCARD
+14
View File
@@ -0,0 +1,14 @@
BEGIN:VCARD
VERSION:4.0
FN:Dummy\, Dummy
N:Dummy;Dummy;;;
ORG:Dummy GmbH
TEL;TYPE=cell;PREF=1:+49 1234 56789
TEL;TYPE=work:+49 9876 54321
EMAIL;TYPE=home:[email protected]
ADR;TYPE=work;LABEL=Dummy-Dummy-Strasse 1 61352 Bad Homburg^nGERMANY^': BH
G01:^n61352 Bad Homburg^nGERMANY:61352 Bad Homburg\nGERMANY:;BHG01:;Dumm
y-Dummy-Strasse 1;Bad Homburg;;61352;Germany
REV;VALUE=DATE-AND-OR-TIME:20210314T092838Z
UID:8b574c60-fd7f-4e99-b584-c5db131ae687
END:VCARD
+55
View File
@@ -0,0 +1,55 @@
BEGIN:VCARD
VERSION:3.0
UID:urn:uuid:7511bfa7-f1f2-53cf-8b7d-b5362dcc0dd7
FN:Mr. John Q. Public\, Esq.
N:Public;John;Quinlan;Mr.;Esq.
N:Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P.
NICKNAME:Robbie
NICKNAME:Jim,Jimmie
PHOTO;VALUE=uri:http://www.abc.com/pub/photos
/jqpublic.gif
PHOTO;ENCODING=b;TYPE=JPEG:SGVsbG8sIHRoaXMgaXMgbm90IGEgcmVhbCBp
bWFnZSBqdXN0IGEgdGVzdC4=
BDAY:1996-04-15
BDAY:1953-10-15T23:10:00Z
BDAY:1987-09-27T08:30:00-06:00
ADR;TYPE=dom,home,postal,parcel:;;123 Main
Street;Any Town;CA;91921-1234
LABEL;TYPE=dom,home,postal,parcel:Mr.John Q. Public\, Esq.\n
Mail Drop: TNE QB\n123 Main Street\nAny Town\, CA 91921-1234
\nU.S.A.
TEL;TYPE=work,voice,pref,msg:+1-213-555-1234
EMAIL;TYPE=internet:[email protected]
EMAIL;TYPE=internet:[email protected]
EMAIL;TYPE=internet,pref:[email protected]
MAILER:PigeonMail 2.1
TZ:-05:00
TZ;VALUE=text:-05:00\; EST\; Raleigh/North America
\;This example has a single value\, not a structure text value.
GEO:37.386013;-122.082932
TITLE:Director\, Research and Development
ROLE:Programmer
LOGO;VALUE=uri:http://www.abc.com/pub/logos/abccorp.jpg
LOGO;ENCODING=b;TYPE=JPEG:SGVsbG8sIHRoaXMgaXMgbm90IGEgcmVhbCBp
bWFnZSBqdXN0IGEgdGVzdC4=
AGENT;VALUE=uri:
CID:[email protected]
AGENT:BEGIN:VCARD\nFN:Susan Thomas\nTEL:+1-919-555-
1234\nEMAIL\;INTERNET:[email protected]\nEND:VCARD\n
ORG:ABC\, Inc.;North American Division;Marketing
CATEGORIES:TRAVEL AGENT
CATEGORIES:INTERNET,IETF,INDUSTRY,INFORMATION TECHNOLOGY
NOTE:This fax number is operational 0800 to 1715
EST\, Mon-Fri.
PRODID:-//ONLINE DIRECTORY//NONSGML Version 1//EN
REV:1995-10-31T22:27:10Z
REV:1997-11-15
SORT-STRING:Harten
SOUND;TYPE=BASIC;VALUE=uri:CID:JOHNQPUBLIC.part8.
[email protected]
SOUND;TYPE=BASIC;ENCODING=b:VGhlcmUgaXMgbm8gc291bmQgaW4gc3BhY2U=
CLASS:PUBLIC
CLASS:PRIVATE
CLASS:CONFIDENTIAL
KEY;ENCODING=b:Tm90IHRoZSBrZXkgeW91IGFyZSBsb29raW5nIGZvcg==
END:VCARD
+23
View File
@@ -0,0 +1,23 @@
BEGIN:VCARD
VERSION:3.0
PRODID:-//Sabre//Sabre VObject 4.1.6//EN
UID:ad612c16-fe12-4ec5-abf6-49998ee5ab88
FN:First Last NextCloud
ADR;TYPE=HOME:PO;Street 2;Street;City;AL;zip;Germany
ADR;TYPE=WORK:PO W;Street 2 W;Street W;City Work;AL work;zip Work;Germany w
ork
EMAIL;TYPE=HOME:[email protected]
EMAIL;TYPE=WORK:[email protected]
TEL;TYPE="HOME,VOICE":11111111
TEL;TYPE=CELL:205333
TEL;TYPE="WORK,FAX":205246\;\;\,\;
TEL;TYPE="WORK,VOICE":222222
ORG:Firma
BDAY:20190210T000033
NICKNAME:Hugo
ITEM1.X-ABDATE;VALUE=DATE-AND-OR-TIME:20190220T000035
ITEM1.X-ABLABEL:_$!<Anniversary>!$_
X-ANNIVERSARY;VALUE=DATE-AND-OR-TIME:20190220T000035
CATEGORIES:Test-Kontakte
REV:20191008T170514Z
END:VCARD
+41
View File
@@ -0,0 +1,41 @@
BEGIN:VCARD
VERSION:3.0
REV:20080409T095515Z
X-YAHOO;TYPE=HOME:yahoo.john
X-GOOGLE TALK;TYPE=WORK:gtalk.john
X-SAMETIME;TYPE=WORK:sametime.john
X-SKYPE;TYPE=WORK:skype.john
X-MSN;TYPE=WORK:msn.john
X-JABBER;TYPE=WORK:jabber.john
N:Doe;John;;;
ADR;TYPE=WORK:;;456 Grandview Building\, Wide Street;San Diego;CA;90204;
United States
ADR;TYPE=HOME:;;123 Sweet Home\, Narrow Street;New York;NY;91102;United
States
URL;TYPE=OTHER:http\://www.homepage.com
URL;TYPE=HOME:http\://www.home.com
URL;TYPE=WORK:http\://www.work.com
URL;TYPE=OTHER:http\://www.other.com
URL;TYPE=OTHER:http\://www.custom.com
ORG:John Doe & Partners Limited;;
TEL;TYPE=WORK:11111111
TEL;TYPE=CELL:22222222
TEL;TYPE=HOME:33333333
TEL;TYPE=OTHER:44444444
TEL;TYPE=FAX:55555555
TEL;TYPE=FAX:66666666
TEL;TYPE=PAGER:77777777
TEL;TYPE=OTHER:88888888
TEL;TYPE=OTHER:99999999
UID:cc548e11-569e-3bf5-a9aa-722de4571f4a
X-ICQ;TYPE=HOME:icq.john
EMAIL;TYPE=WORK,INTERNET:[email protected]
EMAIL;TYPE=HOME,INTERNET:[email protected]
EMAIL;TYPE=OTHER,INTERNET:[email protected]
EMAIL;TYPE=OTHER,INTERNET:[email protected]
TITLE:Sales Manager
X-OTHER;TYPE=WORK:other.john
X-AIM;TYPE=WORK:aim.john
X-QQ;TYPE=WORK:qq.john
FN:John Doe
END:VCARD
+62
View File
@@ -0,0 +1,62 @@
BEGIN:VCARD
VERSION:4.0
NOTE:Mythical Manager\nHyjinx Software Division\n
BabsCo\, Inc.\n
ROLE;LANGUAGE=tr:hoca
TITLE;ALTID=2;LANGUAGE=en:Chief vCard Evangelist
N;ALTID=1;LANGUAGE=en:Yamada;Taro;;;
N;SORT-AS="Harten,Rene":van der Harten;Rene,J.;Sir;R.D.O.N.
N;SORT-AS="Pozo,Oscar":del Pozo Triscon;Oscar;;
SOURCE:ldap://ldap.example.com/cn=Babs%20Jensen,%20o=Babsco,%20c=US
SOURCE:http://directory.example.com/addressbooks/jdoe/
Jean%20Dupont.vcf
KIND:individual
N:Mr. John Q. Public\, Esq.
N:Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P.
NICKNAME;TYPE=work:Boss
PHOTO:data:image/jpeg;base64,MIICajCCAdOgAwIBAgICBEUwDQYJKoZIhv
AQEEBQAwdzELMAkGA1UEBhMCVVMxLDAqBgNVBAoTI05ldHNjYXBlIENvbW11bm
ljYXRpb25zIENvcnBvcmF0aW9uMRwwGgYDVQQLExNJbmZvcm1hdGlvbiBTeXN0
BDAY:19960415
BDAY:--0415
BDAY:19531015T231000Z
BDAY;VALUE=text:circa 1800
GENDER:M
GENDER:F
GENDER:M;Fellow
GENDER:F;grrrl
GENDER:O;intersex
GENDER:;it's complicated
ADR;GEO="geo:12.3457,78.910";LABEL="Mr. John Q. Public, Esq.\n
Mail Drop: TNE QB\n123 Main Street\nAny Town, CA 91921-1234\n
U.S.A.":;;123 Main Street;Any Town;CA;91921-1234;U.S.A.
TEL;VALUE=uri;PREF=1;TYPE="voice,home":tel:+1-555-555-5555;ext=5555
TEL;VALUE=uri;TYPE=home:tel:+33-01-23-45-67
EMAIL;TYPE=work:[email protected]
IMPP;PREF=1:xmpp:[email protected]
LANG;TYPE=work;PREF=1:en
LANG;TYPE=work;PREF=2:fr
TZ;VALUE=utc-offset:-0500
GEO:geo:37.386013,-122.082932
LOGO:http://www.example.com/pub/logos/abccorp.jpg
ORG:ABC\, Inc.;North American Division;Marketing
RELATED;TYPE=friend:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6
RELATED;TYPE=contact:http://example.com/directory/jdoe.vcf
RELATED;TYPE=co-worker;VALUE=text:Please contact my assistant Jane
Doe for any inquiries.
CATEGORIES:TRAVEL AGENT
CATEGORIES:INTERNET,IETF,INDUSTRY,INFORMATION TECHNOLOGY
NOTE:This fax number is operational 0800 to 1715
EST\, Mon-Fri.
PRODID:-//ONLINE DIRECTORY//NONSGML Version 1//EN
REV:19951031T222710Z
SOUND:CID:[email protected]
UID:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6
TEL;PID=3.1,4.2;VALUE=uri:tel:+1-555-555-5555
EMAIL;PID=4.1,5.2:[email protected]
CLIENTPIDMAP:1;urn:uuid:3df403f4-5924-4bb7-b077-3c711d9eb34b
CLIENTPIDMAP:2;urn:uuid:d89c9c7a-2e1b-4832-82de-7e992d95faa5
KEY;MEDIATYPE=application/pgp-keys:ftp://example.com/keys/jdoe
FBURL;MEDIATYPE=text/calendar:ftp://example.com/busy/project-a.ifb
CALADRURI;PREF=1:mailto:[email protected]
END:VCARD
+7
View File
@@ -0,0 +1,7 @@
BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:3c380a90-9a23-59bc-b228-64e45cc23475
EMAIL;PID=4.2,5.1:[email protected]
CLIENTPIDMAP:1;urn:uuid:3eef374e-7179-4196-a914-27358c3e6527
CLIENTPIDMAP:2;urn:uuid:42bcd5a7-1699-4514-87b4-056edf68e9cc
END:VCARD
+7
View File
@@ -0,0 +1,7 @@
BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:9f501a4e-a0df-5d90-8ff9-64e811b8ec15
EMAIL;PID=5.1,5.2:[email protected]
CLIENTPIDMAP:1;urn:uuid:0c75c629-6a8d-4d5e-a07f-1bb35846854d
CLIENTPIDMAP:2;urn:uuid:3eef374e-7179-4196-a914-27358c3e6527
END:VCARD
+9
View File
@@ -0,0 +1,9 @@
BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b1
FN;PID=1.1:J. Doe
N:Doe;J.;;;
EMAIL;PID=1.1:[email protected]
TEL;PID=1.1;VALUE=uri:tel:+1-555-555-5555
CLIENTPIDMAP:1;urn:uuid:53e374d9-337e-4727-8803-a1e9c14e0556
END:VCARD
+12
View File
@@ -0,0 +1,12 @@
BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:d133e5b7-baa2-5520-9766-965ca3835985
FN;PID=1.1:J. Doe
N:Doe;J.;;;
EMAIL;PID=1.1:[email protected]
EMAIL;PID=2.2:[email protected]
TEL;PID=1.1;VALUE=uri:tel:+1-555-555-5555
TEL;PID=2.2;VALUE=uri:tel:+1-666-666-6666
CLIENTPIDMAP:1;urn:uuid:53e374d9-337e-4727-8803-a1e9c14e0556
CLIENTPIDMAP:2;urn:uuid:1f762d2b-03c4-4a83-9a03-75ff658a6eee
END:VCARD
+8
View File
@@ -0,0 +1,8 @@
BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:7c9ba2ad-f8ab-5747-9738-ed156bad9270
KIND:group
FN:The Doe family
MEMBER:urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af
MEMBER:urn:uuid:b8767877-b4a1-4c70-9acc-505d3819e519
END:VCARD
+5
View File
@@ -0,0 +1,5 @@
BEGIN:VCARD
VERSION:4.0
FN:John Doe
UID:urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af
END:VCARD
+10
View File
@@ -0,0 +1,10 @@
BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:e5b9689c-02bd-5c95-a857-bad3b5bd8cf4
KIND:group
FN:Funky distribution list
MEMBER:mailto:[email protected]
MEMBER:xmpp:[email protected]
MEMBER:sip:[email protected]
MEMBER:tel:+1-418-555-5555
END:VCARD
+22
View File
@@ -0,0 +1,22 @@
BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:61bbee4f-102c-5d99-8ed2-5543c8f44b8d
FN:Simon Perreault
N:Perreault;Simon;;;ing. jr,M.Sc.
BDAY:--0203
ANNIVERSARY:20090808T1430-0500
GENDER:M
LANG;PREF=1:fr
LANG;PREF=2:en
ORG;TYPE=work:Viagenie
ADR;TYPE=work:;Suite D2-630;2875 Laurier;
Quebec;QC;G1V 2M2;Canada
TEL;VALUE=uri;TYPE=work,voice;PREF=1:tel:+1-418-656-9254;ext=102
TEL;VALUE=uri;TYPE=work,cell,voice,video,text:tel:+1-418-262-6501
EMAIL;TYPE=work:[email protected]
GEO;TYPE=work:geo:46.772673,-71.282945
KEY;TYPE=work;VALUE=uri:
http://www.viagenie.ca/simon.perreault/simon.asc
TZ:-0500
URL;TYPE=home:http://nomis80.org
END:VCARD
+1364
View File
File diff suppressed because it is too large Load Diff
+898
View File
@@ -0,0 +1,898 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io::{BufReader, Read};
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
use std::time::Duration;
use ureq::Agent;
use ureq::Body;
use ureq::config::{Config, RedirectAuthHeaders};
use ureq::http::{Method, Request, Response};
use ureq::tls::{RootCerts, TlsConfig};
use crate::dav::parse::{ControlStrippingReader, DavResponse, parse_multistatus};
use crate::dav::retry::{DavOutcome, classify};
use crate::jmap::error::JmapError;
use crate::jmap::http::{Auth, RetryPolicy, retry_after_header};
use crate::jmap::retry::{self, RateLimitState};
use crate::logging::{LEVEL_BODIES, LEVEL_DEFAULT, LEVEL_PROGRESS, Logger};
const MAX_BODY: u64 = 512 * 1024 * 1024;
const LONG_RETRY_THRESHOLD: Duration = Duration::from_secs(10);
const MAX_DAV_REDIRECTS: u32 = 5;
const ACCEPT_DAV: &str = "application/xml, text/xml;q=0.9, */*;q=0.5";
const ACCEPT_BINARY: &str = "*/*";
#[derive(Debug)]
pub struct DavHttpResponse {
pub status: u16,
pub body: Vec<u8>,
pub etag: Option<String>,
pub content_type: Option<String>,
pub last_modified: Option<String>,
}
#[derive(Debug)]
pub struct MultiStatus {
pub status: u16,
pub responses: Vec<DavResponse>,
pub final_url: String,
}
struct Inner {
agent: Agent,
auth: Auth,
retry: RetryPolicy,
rate_limit: RateLimitState,
log_level: AtomicU8,
retries_total: AtomicU64,
retry_after_sleeps: AtomicU64,
user_agent: String,
}
#[derive(Clone)]
pub struct DavClient {
inner: Arc<Inner>,
}
impl DavClient {
pub fn new(auth: Auth, retry: RetryPolicy, allow_invalid_certs: bool) -> Self {
let config: Config = Config::builder()
.http_status_as_error(false)
.allow_non_standard_methods(true)
.max_redirects(0)
.redirect_auth_headers(RedirectAuthHeaders::SameHost)
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.disable_verification(allow_invalid_certs)
.build(),
)
.build();
DavClient {
inner: Arc::new(Inner {
agent: config.new_agent(),
auth,
retry,
rate_limit: RateLimitState::new(),
log_level: AtomicU8::new(LEVEL_DEFAULT),
retries_total: AtomicU64::new(0),
retry_after_sleeps: AtomicU64::new(0),
user_agent: format!("vandelay/{}", env!("CARGO_PKG_VERSION")),
}),
}
}
pub fn set_logger(&self, logger: Logger) {
self.inner
.log_level
.store(logger.level(), Ordering::Relaxed);
}
pub fn logger(&self) -> Logger {
Logger::new(self.inner.log_level.load(Ordering::Relaxed))
}
pub fn rate_limit(&self) -> &RateLimitState {
&self.inner.rate_limit
}
pub fn retries_observed(&self) -> u64 {
self.inner.retries_total.load(Ordering::Relaxed)
}
pub fn retry_after_sleeps(&self) -> u64 {
self.inner.retry_after_sleeps.load(Ordering::Relaxed)
}
pub fn propfind(&self, url: &str, depth: u8, body: &str) -> Result<DavHttpResponse, JmapError> {
self.execute(
"PROPFIND",
url,
Some(body.as_bytes()),
Some("application/xml; charset=utf-8"),
Some(depth),
ACCEPT_DAV,
)
}
pub fn report(&self, url: &str, depth: u8, body: &str) -> Result<DavHttpResponse, JmapError> {
self.execute(
"REPORT",
url,
Some(body.as_bytes()),
Some("application/xml; charset=utf-8"),
Some(depth),
ACCEPT_DAV,
)
}
pub fn get(&self, url: &str) -> Result<DavHttpResponse, JmapError> {
self.execute("GET", url, None, None, None, ACCEPT_BINARY)
}
pub fn propfind_responses(
&self,
url: &str,
depth: u8,
body: &str,
base_url: &str,
) -> Result<MultiStatus, JmapError> {
self.stream_multistatus("PROPFIND", url, Some(body.as_bytes()), depth, base_url)
}
pub fn report_responses(
&self,
url: &str,
depth: u8,
body: &str,
base_url: &str,
) -> Result<MultiStatus, JmapError> {
self.stream_multistatus("REPORT", url, Some(body.as_bytes()), depth, base_url)
}
pub fn get_stream(&self, url: &str) -> Result<DavStream, JmapError> {
let logger = self.logger();
let policy = self.inner.retry;
let mut attempt: u32 = 0;
let original_url = url.to_owned();
let mut current_url = url.to_owned();
let mut redirects: u32 = 0;
let mut include_auth = true;
loop {
self.inner.rate_limit.cooldown().wait();
let outcome = self.one_attempt_stream(&current_url, include_auth);
match outcome {
AttemptStream::Ok {
status,
body_reader,
etag,
content_type,
last_modified,
..
} if (200..300).contains(&status) => {
self.inner.rate_limit.on_success();
return Ok(DavStream {
status,
body_reader,
etag,
content_type,
last_modified,
});
}
AttemptStream::Ok { status, .. } if status == 404 || status == 410 => {
self.inner.rate_limit.on_success();
return Ok(DavStream {
status,
body_reader: Box::new(std::io::empty()),
etag: None,
content_type: None,
last_modified: None,
});
}
AttemptStream::Ok {
status,
location: Some(loc),
..
} if is_redirect_status(status) => {
if redirects >= MAX_DAV_REDIRECTS {
return Err(JmapError::Connect(format!(
"too many redirects ({redirects}) starting from {url}"
)));
}
redirects += 1;
let next = resolve_redirect(&current_url, &loc)?;
if include_auth && !same_host(&original_url, &next) {
include_auth = false;
if logger.enabled(LEVEL_PROGRESS) {
eprintln!(
"DAV redirect leaves origin host {original_url}; dropping Authorization for {next}"
);
}
}
if logger.enabled(LEVEL_PROGRESS) {
eprintln!("DAV {status}: {current_url} -> {next}");
}
current_url = next;
}
AttemptStream::Ok {
status,
body_reader,
retry_after,
..
} => {
let mut body_bytes = Vec::new();
let mut limited = body_reader.take(64 * 1024);
let _ = limited.read_to_end(&mut body_bytes);
self.handle_status(
&logger,
status,
&body_bytes,
retry_after,
&mut attempt,
&policy,
)?;
}
AttemptStream::Transport(err) => {
self.handle_transport(&logger, err, &mut attempt, &policy)?;
}
}
}
}
fn execute(
&self,
method: &str,
url: &str,
body: Option<&[u8]>,
content_type: Option<&str>,
depth: Option<u8>,
accept: &str,
) -> Result<DavHttpResponse, JmapError> {
let logger = self.logger();
let policy = self.inner.retry;
let mut attempt: u32 = 0;
let original_url = url.to_owned();
let mut current_url = url.to_owned();
let mut redirects: u32 = 0;
let mut include_auth = true;
loop {
self.inner.rate_limit.cooldown().wait();
let outcome = self.one_attempt(WireRequest {
method,
url: &current_url,
body,
content_type,
depth,
accept,
include_auth,
});
match outcome {
Attempt::Ok {
status,
body,
etag,
content_type,
last_modified,
..
} if (200..300).contains(&status) => {
self.inner.rate_limit.on_success();
return Ok(DavHttpResponse {
status,
body,
etag,
content_type,
last_modified,
});
}
Attempt::Ok {
status,
location: Some(loc),
..
} if is_redirect_status(status) => {
if redirects >= MAX_DAV_REDIRECTS {
return Err(JmapError::Connect(format!(
"too many redirects ({redirects}) starting from {url}"
)));
}
redirects += 1;
let next = resolve_redirect(&current_url, &loc)?;
if include_auth && !same_host(&original_url, &next) {
include_auth = false;
if logger.enabled(LEVEL_PROGRESS) {
eprintln!(
"DAV redirect leaves origin host {original_url}; dropping Authorization for {next}"
);
}
}
if logger.enabled(LEVEL_PROGRESS) {
eprintln!("DAV {status}: {current_url} -> {next}");
}
current_url = next;
}
Attempt::Ok { status, body, .. } if is_redirect_status(status) => {
return Err(JmapError::Connect(format!(
"{method} {current_url} returned {status} without Location: {}",
truncate(&body)
)));
}
Attempt::Ok {
status,
body,
retry_after,
etag: _,
content_type: _,
last_modified: _,
location: _,
} => {
let disposition = classify(status, &body);
match disposition {
DavOutcome::Vanished => {
return Ok(DavHttpResponse {
status,
body,
etag: None,
content_type: None,
last_modified: None,
});
}
DavOutcome::Auth => {
return Err(JmapError::Auth(format!(
"server returned {status}: {}",
truncate(&body)
)));
}
DavOutcome::Fatal => {
return Err(JmapError::HttpStatus {
status,
body: truncate(&body),
});
}
DavOutcome::Retryable => {
attempt += 1;
self.inner.retries_total.fetch_add(1, Ordering::Relaxed);
if attempt > policy.max_retries {
return Err(JmapError::RetriesExhausted(format!(
"{method} {current_url} kept returning {status}"
)));
}
if retry_after.is_some() {
self.inner
.retry_after_sleeps
.fetch_add(1, Ordering::Relaxed);
}
let delay = self.inner.rate_limit.on_throttle(&policy, retry_after);
if delay >= LONG_RETRY_THRESHOLD {
logger.warn(&format!(
"DAV {method} rate-limited (http {status}); waiting {}s before retry {}/{}",
delay.as_secs(),
attempt,
policy.max_retries,
));
}
if logger.enabled(LEVEL_BODIES) {
eprintln!(
"retry {}/{} {method} {current_url} after {:?} (http {status}) body={}",
attempt,
policy.max_retries,
delay,
truncate(&body),
);
} else if logger.enabled(LEVEL_PROGRESS) {
eprintln!(
"retry {}/{} (http {status})",
attempt, policy.max_retries
);
}
std::thread::sleep(delay);
}
DavOutcome::Success => unreachable!(),
}
}
Attempt::Transport(err) => {
self.handle_transport(&logger, err, &mut attempt, &policy)?;
}
}
}
}
fn stream_multistatus(
&self,
method: &str,
url: &str,
body: Option<&[u8]>,
depth: u8,
base_url: &str,
) -> Result<MultiStatus, JmapError> {
let logger = self.logger();
let policy = self.inner.retry;
let mut attempt: u32 = 0;
let original_url = url.to_owned();
let mut current_url = url.to_owned();
let mut redirects: u32 = 0;
let mut include_auth = true;
loop {
self.inner.rate_limit.cooldown().wait();
let send = self.build_and_send(WireRequest {
method,
url: &current_url,
body,
content_type: Some("application/xml; charset=utf-8"),
depth: Some(depth),
accept: ACCEPT_DAV,
include_auth,
});
match send {
Ok(mut resp) => {
let status = resp.status().as_u16();
let retry_after = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(retry_after_header);
let location = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
if is_redirect_status(status) {
let Some(loc) = location else {
return Err(JmapError::Connect(format!(
"{method} {current_url} returned {status} without Location"
)));
};
if redirects >= MAX_DAV_REDIRECTS {
return Err(JmapError::Connect(format!(
"too many redirects ({redirects}) starting from {url}"
)));
}
redirects += 1;
let next = resolve_redirect(&current_url, &loc)?;
if include_auth && !same_host(&original_url, &next) {
include_auth = false;
if logger.enabled(LEVEL_PROGRESS) {
eprintln!(
"DAV redirect leaves origin host {original_url}; dropping Authorization for {next}"
);
}
}
if logger.enabled(LEVEL_PROGRESS) {
eprintln!("DAV {status}: {current_url} -> {next}");
}
current_url = next;
continue;
}
if (200..300).contains(&status) {
self.inner.rate_limit.on_success();
let parse_base = if current_url == url {
base_url
} else {
&current_url
};
let raw = resp.into_body().into_reader();
let stripped = ControlStrippingReader::new(raw);
let reader = BufReader::new(stripped);
let responses = parse_multistatus(reader, parse_base)
.map_err(|e| JmapError::Malformed(format!("multistatus parse: {e}")))?;
return Ok(MultiStatus {
status,
responses,
final_url: current_url.clone(),
});
}
let body_bytes =
match resp.body_mut().with_config().limit(64 * 1024).read_to_vec() {
Ok(b) => b,
Err(e) => {
return Err(JmapError::Transport(format!(
"reading error body: {e}"
)));
}
};
let disposition = classify(status, &body_bytes);
match disposition {
DavOutcome::Vanished => {
return Ok(MultiStatus {
status,
responses: Vec::new(),
final_url: current_url.clone(),
});
}
DavOutcome::Auth => {
return Err(JmapError::Auth(format!(
"server returned {status}: {}",
truncate(&body_bytes)
)));
}
DavOutcome::Fatal => {
return Err(JmapError::HttpStatus {
status,
body: truncate(&body_bytes),
});
}
DavOutcome::Retryable => {
attempt += 1;
self.inner.retries_total.fetch_add(1, Ordering::Relaxed);
if attempt > policy.max_retries {
return Err(JmapError::RetriesExhausted(format!(
"{method} {url} kept returning {status}"
)));
}
if retry_after.is_some() {
self.inner
.retry_after_sleeps
.fetch_add(1, Ordering::Relaxed);
}
let delay = self.inner.rate_limit.on_throttle(&policy, retry_after);
if delay >= LONG_RETRY_THRESHOLD {
logger.warn(&format!(
"DAV {method} rate-limited (http {status}); waiting {}s before retry {}/{}",
delay.as_secs(),
attempt,
policy.max_retries,
));
}
std::thread::sleep(delay);
}
DavOutcome::Success => unreachable!(),
}
}
Err(e) => {
let err = map_ureq_error(e);
self.handle_transport(&logger, err, &mut attempt, &policy)?;
}
}
}
}
fn handle_status(
&self,
logger: &Logger,
status: u16,
body: &[u8],
retry_after: Option<Duration>,
attempt: &mut u32,
policy: &RetryPolicy,
) -> Result<(), JmapError> {
let disposition = classify(status, body);
match disposition {
DavOutcome::Vanished => Err(JmapError::HttpStatus {
status,
body: truncate(body),
}),
DavOutcome::Auth => Err(JmapError::Auth(format!("server returned {status}"))),
DavOutcome::Fatal => Err(JmapError::HttpStatus {
status,
body: truncate(body),
}),
DavOutcome::Retryable => {
*attempt += 1;
self.inner.retries_total.fetch_add(1, Ordering::Relaxed);
if *attempt > policy.max_retries {
return Err(JmapError::RetriesExhausted(format!(
"GET stream kept returning {status}"
)));
}
if retry_after.is_some() {
self.inner
.retry_after_sleeps
.fetch_add(1, Ordering::Relaxed);
}
let delay = self.inner.rate_limit.on_throttle(policy, retry_after);
if delay >= LONG_RETRY_THRESHOLD {
logger.warn(&format!(
"DAV stream rate-limited (http {status}); waiting {}s",
delay.as_secs()
));
}
std::thread::sleep(delay);
Ok(())
}
DavOutcome::Success => Ok(()),
}
}
fn handle_transport(
&self,
logger: &Logger,
err: JmapError,
attempt: &mut u32,
policy: &RetryPolicy,
) -> Result<(), JmapError> {
match transport_disposition(&err) {
retry::Disposition::Fatal => Err(err),
retry::Disposition::Retryable => {
*attempt += 1;
self.inner.retries_total.fetch_add(1, Ordering::Relaxed);
if *attempt > policy.max_retries {
return Err(JmapError::RetriesExhausted(format!("{err}")));
}
let delay = retry::backoff_delay(policy, *attempt);
if delay >= LONG_RETRY_THRESHOLD {
logger.warn(&format!(
"transient transport failure ({err}); waiting {}s before retry {}/{}",
delay.as_secs(),
attempt,
policy.max_retries,
));
}
std::thread::sleep(delay);
Ok(())
}
}
}
fn one_attempt(&self, req: WireRequest<'_>) -> Attempt {
match self.build_and_send(req) {
Ok(mut resp) => {
let status = resp.status().as_u16();
let etag = header_str(&resp, "etag");
let content_type_header = header_str(&resp, "content-type");
let last_modified = header_str(&resp, "last-modified");
let location = header_str(&resp, "location");
let retry_after = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(retry_after_header);
if is_redirect_status(status) && location.is_some() {
return Attempt::Ok {
status,
body: Vec::new(),
retry_after,
etag,
content_type: content_type_header,
last_modified,
location,
};
}
let body_limit = if (200..300).contains(&status) {
MAX_BODY
} else {
64 * 1024
};
match resp
.body_mut()
.with_config()
.limit(body_limit)
.read_to_vec()
{
Ok(bytes) => Attempt::Ok {
status,
body: bytes,
retry_after,
etag,
content_type: content_type_header,
last_modified,
location,
},
Err(e) => Attempt::Transport(JmapError::Transport(format!(
"reading response body: {e}"
))),
}
}
Err(e) => Attempt::Transport(map_ureq_error(e)),
}
}
fn one_attempt_stream(&self, url: &str, include_auth: bool) -> AttemptStream {
let req = WireRequest {
method: "GET",
url,
body: None,
content_type: None,
depth: None,
accept: ACCEPT_BINARY,
include_auth,
};
match self.build_and_send(req) {
Ok(resp) => {
let status = resp.status().as_u16();
let etag = header_str(&resp, "etag");
let content_type = header_str(&resp, "content-type");
let last_modified = header_str(&resp, "last-modified");
let location = header_str(&resp, "location");
let retry_after = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(retry_after_header);
let body_reader = resp.into_body().into_reader();
AttemptStream::Ok {
status,
body_reader: Box::new(body_reader),
etag,
content_type,
last_modified,
location,
retry_after,
}
}
Err(e) => AttemptStream::Transport(map_ureq_error(e)),
}
}
fn build_and_send(&self, req: WireRequest<'_>) -> Result<Response<Body>, ureq::Error> {
let method_parsed = Method::from_bytes(req.method.as_bytes())
.map_err(|e| ureq::Error::Other(Box::new(std::io::Error::other(e))))?;
let mut builder = Request::builder()
.method(method_parsed)
.uri(req.url)
.header("Accept", req.accept)
.header("User-Agent", self.inner.user_agent.as_str());
if req.include_auth {
builder = builder.header("Authorization", self.inner.auth.header_value());
}
if let Some(d) = req.depth {
builder = builder.header("Depth", d.to_string());
}
if let Some(ct) = req.content_type {
builder = builder.header("Content-Type", ct);
}
let payload: &[u8] = req.body.unwrap_or(&[]);
let request = builder
.body(payload)
.map_err(|e| ureq::Error::Other(Box::new(std::io::Error::other(e))))?;
self.inner.agent.run(request)
}
}
struct WireRequest<'a> {
method: &'a str,
url: &'a str,
body: Option<&'a [u8]>,
content_type: Option<&'a str>,
depth: Option<u8>,
accept: &'a str,
include_auth: bool,
}
pub struct DavStream {
pub status: u16,
pub body_reader: Box<dyn Read + Send>,
pub etag: Option<String>,
pub content_type: Option<String>,
pub last_modified: Option<String>,
}
impl Read for DavStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.body_reader.read(buf)
}
}
enum Attempt {
Ok {
status: u16,
body: Vec<u8>,
retry_after: Option<Duration>,
etag: Option<String>,
content_type: Option<String>,
last_modified: Option<String>,
location: Option<String>,
},
Transport(JmapError),
}
enum AttemptStream {
Ok {
status: u16,
body_reader: Box<dyn Read + Send>,
etag: Option<String>,
content_type: Option<String>,
last_modified: Option<String>,
location: Option<String>,
retry_after: Option<Duration>,
},
Transport(JmapError),
}
fn header_str(resp: &Response<Body>, name: &str) -> Option<String> {
resp.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
}
fn is_redirect_status(status: u16) -> bool {
matches!(status, 301 | 302 | 303 | 307 | 308)
}
fn resolve_redirect(current: &str, location: &str) -> Result<String, JmapError> {
crate::dav::href::join_absolute(current, location)
.map_err(|e| JmapError::Connect(format!("redirect: {e}")))
}
fn url_host_and_port(url: &str) -> Option<(String, Option<u16>)> {
let parsed = url::Url::parse(url).ok()?;
let host = parsed.host_str()?.to_owned();
Some((host, parsed.port_or_known_default()))
}
fn same_host(a: &str, b: &str) -> bool {
match (url_host_and_port(a), url_host_and_port(b)) {
(Some(x), Some(y)) => x == y,
_ => false,
}
}
fn transport_disposition(err: &JmapError) -> retry::Disposition {
match err {
JmapError::Connect(_) => retry::Disposition::Fatal,
_ => retry::Disposition::Retryable,
}
}
fn map_ureq_error(err: ureq::Error) -> JmapError {
match err {
ureq::Error::Io(e) => JmapError::Transport(format!("io: {e}")),
ureq::Error::Timeout(t) => JmapError::Transport(format!("timeout: {t}")),
ureq::Error::HostNotFound => JmapError::Transport("host not found".to_owned()),
ureq::Error::ConnectionFailed => JmapError::Transport("connection failed".to_owned()),
ureq::Error::BodyStalled => JmapError::Transport("body stalled".to_owned()),
ureq::Error::Tls(m) => JmapError::Transport(format!("tls: {m}")),
ureq::Error::TooManyRedirects => JmapError::Connect("too many redirects".to_owned()),
ureq::Error::RedirectFailed => JmapError::Connect("redirect failed".to_owned()),
ureq::Error::TlsRequired => {
JmapError::Connect("server requires TLS but transport is unsecured".to_owned())
}
other => JmapError::Connect(other.to_string()),
}
}
fn truncate(body: &[u8]) -> String {
let text = String::from_utf8_lossy(body);
if text.len() <= 512 {
return text.into_owned();
}
let end = text.floor_char_boundary(512);
format!("{}...", &text[..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_constructs_cleanly() {
let c = DavClient::new(
Auth::Basic {
user: "u".into(),
password: "p".into(),
},
RetryPolicy::new(3),
false,
);
assert_eq!(c.retries_observed(), 0);
assert_eq!(c.retry_after_sleeps(), 0);
}
#[test]
fn auth_header_appears_via_builder() {
let c = DavClient::new(
Auth::Bearer {
token: "abc".into(),
},
RetryPolicy::new(0),
false,
);
let logger = c.logger();
assert_eq!(logger.level(), LEVEL_DEFAULT);
}
#[test]
fn truncate_preserves_char_boundaries() {
let mut body = vec![b'a'; 510];
body.extend_from_slice("\u{1F4A9}".as_bytes());
body.extend_from_slice(&[b'b'; 600]);
let s = truncate(&body);
assert!(s.ends_with("..."));
assert!(s.is_char_boundary(s.len()));
}
}
+327
View File
@@ -0,0 +1,327 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use crate::dav::client::DavClient;
use crate::dav::href::{Href, absolute_url, normalise};
use crate::dav::parse::DavResponse;
use crate::dav::xml;
use crate::jmap::error::JmapError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DavKind {
Caldav,
Carddav,
Webdav,
}
impl DavKind {
pub fn well_known_uri(self) -> Option<&'static str> {
match self {
DavKind::Caldav => Some("/.well-known/caldav"),
DavKind::Carddav => Some("/.well-known/carddav"),
DavKind::Webdav => None,
}
}
pub fn home_set_body(self) -> Option<String> {
match self {
DavKind::Caldav => Some(xml::propfind_calendar_home_set()),
DavKind::Carddav => Some(xml::propfind_addressbook_home_set()),
DavKind::Webdav => None,
}
}
pub fn principal_and_home_set_body(self) -> Option<String> {
match self {
DavKind::Caldav => Some(xml::propfind_principal_and_calendar_home_set()),
DavKind::Carddav => Some(xml::propfind_principal_and_addressbook_home_set()),
DavKind::Webdav => None,
}
}
pub fn collection_listing_body(self) -> String {
match self {
DavKind::Caldav => xml::propfind_calendar_collections(),
DavKind::Carddav => xml::propfind_addressbook_collections(),
DavKind::Webdav => xml::propfind_webdav_listing(),
}
}
pub fn extract_home_set(self, props: &crate::dav::parse::ResourceProps) -> Option<&str> {
match self {
DavKind::Caldav => props.calendar_home_set.as_deref(),
DavKind::Carddav => props.addressbook_home_set.as_deref(),
DavKind::Webdav => None,
}
}
pub fn item_resourcetype_matches(self, props: &crate::dav::parse::ResourceProps) -> bool {
match self {
DavKind::Caldav => props.is_calendar,
DavKind::Carddav => props.is_addressbook,
DavKind::Webdav => props.is_collection,
}
}
}
#[derive(Debug, Clone)]
pub struct DiscoveredCollection {
pub url: String,
pub href: Href,
pub props: crate::dav::parse::ResourceProps,
}
#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
#[error("transport: {0}")]
Transport(#[from] JmapError),
#[error("xml: {0}")]
Parse(#[from] crate::dav::parse::ParseError),
#[error("href: {0}")]
Href(#[from] crate::dav::href::HrefError),
#[error("no DAV collections found under {url}")]
NotFound { url: String },
}
#[derive(Debug, Clone)]
pub struct Discovery {
pub principal_url: Option<String>,
pub home_set_url: String,
pub collections: Vec<DiscoveredCollection>,
}
pub fn discover(
client: &DavClient,
kind: DavKind,
user_url: &str,
) -> Result<Discovery, DiscoveryError> {
if let Some(disc) = try_treat_url_as_home_or_collection(client, kind, user_url)? {
return Ok(disc);
}
if let Some(disc) = try_via_principal(client, kind, user_url)? {
return Ok(disc);
}
if let Some(well_known) = kind.well_known_uri() {
let absolute = absolute_url(user_url, &Href::from_normalised(well_known.to_owned()))?;
if let Some(disc) = try_via_principal(client, kind, &absolute)? {
return Ok(disc);
}
if let Some(disc) = try_treat_url_as_home_or_collection(client, kind, &absolute)? {
return Ok(disc);
}
}
Err(DiscoveryError::NotFound {
url: user_url.to_owned(),
})
}
fn try_treat_url_as_home_or_collection(
client: &DavClient,
kind: DavKind,
url: &str,
) -> Result<Option<Discovery>, DiscoveryError> {
let body = kind.collection_listing_body();
let collections = match propfind_collections(client, kind, url, &body) {
Ok(c) => c,
Err(DiscoveryError::Transport(JmapError::HttpStatus { status, .. }))
if (400..500).contains(&status) =>
{
return Ok(None);
}
Err(e) => return Err(e),
};
if collections.is_empty() {
return Ok(None);
}
Ok(Some(Discovery {
principal_url: None,
home_set_url: url.to_owned(),
collections,
}))
}
fn try_via_principal(
client: &DavClient,
kind: DavKind,
url: &str,
) -> Result<Option<Discovery>, DiscoveryError> {
let Some(combined_body) = kind.principal_and_home_set_body() else {
return Ok(None);
};
let first_ms = match client.propfind_responses(url, 0, &combined_body, url) {
Ok(ms) => ms,
Err(JmapError::HttpStatus { status, .. }) if (400..600).contains(&status) => {
return Ok(None);
}
Err(JmapError::RetriesExhausted(_)) => return Ok(None),
Err(e) => return Err(DiscoveryError::Transport(e)),
};
let first_final_url = first_ms.final_url.clone();
let direct_home_set = first_ms
.responses
.iter()
.find_map(|r| kind.extract_home_set(&r.props))
.map(|h| h.to_owned());
let (principal_url, home_set_url) = if let Some(home) = direct_home_set {
let resolved = absolute_url(&first_final_url, &normalise(&first_final_url, &home)?)?;
(first_final_url.clone(), resolved)
} else {
let principal_href = first_ms
.responses
.iter()
.find_map(|r| r.props.current_user_principal.as_deref())
.map(|h| h.to_owned());
let principal_url = match principal_href {
Some(rel) => absolute_url(&first_final_url, &normalise(&first_final_url, &rel)?)?,
None => first_final_url.clone(),
};
let Some(home_set_body) = kind.home_set_body() else {
return Ok(None);
};
let home_ms =
match client.propfind_responses(&principal_url, 0, &home_set_body, &principal_url) {
Ok(ms) => ms,
Err(JmapError::HttpStatus { status, .. }) if (400..600).contains(&status) => {
return Ok(None);
}
Err(JmapError::RetriesExhausted(_)) => return Ok(None),
Err(e) => return Err(DiscoveryError::Transport(e)),
};
let principal_after_redirect = home_ms.final_url.clone();
let home_set_href = home_ms
.responses
.iter()
.find_map(|r| kind.extract_home_set(&r.props))
.map(|h| h.to_owned());
let Some(home_set_rel) = home_set_href else {
return Ok(None);
};
let resolved = absolute_url(
&principal_after_redirect,
&normalise(&principal_after_redirect, &home_set_rel)?,
)?;
(principal_url, resolved)
};
let listing_body = kind.collection_listing_body();
let collections = propfind_collections(client, kind, &home_set_url, &listing_body)?;
if collections.is_empty() {
return Ok(None);
}
Ok(Some(Discovery {
principal_url: Some(principal_url),
home_set_url,
collections,
}))
}
fn propfind_collections(
client: &DavClient,
kind: DavKind,
url: &str,
body: &str,
) -> Result<Vec<DiscoveredCollection>, DiscoveryError> {
let responses = propfind_depth(client, url, 1, body)?;
let self_href = normalise(url, "").map(|h| h.into_string()).ok();
let mut out: Vec<DiscoveredCollection> = Vec::new();
for r in responses {
let is_self = self_href.as_deref() == Some(r.href.as_str());
if matches!(kind, DavKind::Webdav) {
if !r.props.is_collection {
continue;
}
if !is_self {
continue;
}
} else {
if !kind.item_resourcetype_matches(&r.props) {
continue;
}
if calendar_supports_only_vfreebusy(&r.props) {
client.logger().warn(&format!(
"calendar {} supports only {:?}; skipping",
r.href.as_str(),
r.props.supported_components,
));
continue;
}
}
let abs = absolute_url(url, &r.href)?;
out.push(DiscoveredCollection {
url: abs,
href: r.href.clone(),
props: r.props,
});
}
Ok(out)
}
fn calendar_supports_only_vfreebusy(props: &crate::dav::parse::ResourceProps) -> bool {
if !props.is_calendar {
return false;
}
if props.supported_components.is_empty() {
return false;
}
let has_useful = props
.supported_components
.iter()
.any(|c| matches!(c.as_str(), "VEVENT" | "VTODO" | "VJOURNAL"));
!has_useful
}
fn propfind_depth(
client: &DavClient,
url: &str,
depth: u8,
body: &str,
) -> Result<Vec<DavResponse>, DiscoveryError> {
let ms = client.propfind_responses(url, depth, body, url)?;
Ok(ms.responses)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn well_known_uris_match_rfc_6764() {
assert_eq!(
DavKind::Caldav.well_known_uri(),
Some("/.well-known/caldav")
);
assert_eq!(
DavKind::Carddav.well_known_uri(),
Some("/.well-known/carddav")
);
assert_eq!(DavKind::Webdav.well_known_uri(), None);
}
#[test]
fn home_set_body_only_for_calendar_addressbook() {
assert!(DavKind::Caldav.home_set_body().is_some());
assert!(DavKind::Carddav.home_set_body().is_some());
assert!(DavKind::Webdav.home_set_body().is_none());
}
#[test]
fn item_resourcetype_matches_calendar_for_caldav() {
let p = crate::dav::parse::ResourceProps {
is_calendar: true,
..Default::default()
};
assert!(DavKind::Caldav.item_resourcetype_matches(&p));
assert!(!DavKind::Carddav.item_resourcetype_matches(&p));
}
#[test]
fn item_resourcetype_matches_collection_for_webdav() {
let p = crate::dav::parse::ResourceProps {
is_collection: true,
..Default::default()
};
assert!(DavKind::Webdav.item_resourcetype_matches(&p));
}
}
+260
View File
@@ -0,0 +1,260 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Href(String);
impl Href {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
pub fn from_normalised(s: String) -> Href {
Href(s)
}
}
impl AsRef<str> for Href {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, thiserror::Error)]
pub enum HrefError {
#[error("invalid base URL {url:?}: {source}")]
InvalidBase {
url: String,
#[source]
source: url::ParseError,
},
#[error("invalid href {href:?} relative to {base:?}: {source}")]
InvalidHref {
href: String,
base: String,
#[source]
source: url::ParseError,
},
}
pub fn normalise(base: &str, raw: &str) -> Result<Href, HrefError> {
let base_url = Url::parse(base).map_err(|e| HrefError::InvalidBase {
url: base.to_owned(),
source: e,
})?;
let resolved = base_url
.join(raw.trim())
.map_err(|e| HrefError::InvalidHref {
href: raw.to_owned(),
base: base.to_owned(),
source: e,
})?;
Ok(normalise_path(&resolved))
}
pub fn normalise_path(url: &Url) -> Href {
let mut path = url.path().to_owned();
if path.is_empty() {
path.push('/');
}
let decoded = percent_decode(&path);
let reencoded = percent_encode_safe(&decoded);
Href(reencoded)
}
pub fn absolute_url(base: &str, href: &Href) -> Result<String, HrefError> {
join_absolute(base, href.as_str())
}
pub fn join_absolute(base: &str, href: &str) -> Result<String, HrefError> {
let base_url = Url::parse(base).map_err(|e| HrefError::InvalidBase {
url: base.to_owned(),
source: e,
})?;
let resolved = base_url.join(href).map_err(|e| HrefError::InvalidHref {
href: href.to_owned(),
base: base.to_owned(),
source: e,
})?;
Ok(resolved.to_string())
}
pub fn last_path_component(href: &Href) -> String {
let trimmed = href.as_str().trim_end_matches('/');
let tail = trimmed.rsplit('/').next().unwrap_or("");
percent_decode(tail)
}
pub fn parent_collection(href: &Href) -> Href {
let trimmed = href.as_str().trim_end_matches('/');
if let Some(slash) = trimmed.rfind('/') {
let parent = &trimmed[..=slash];
Href(parent.to_owned())
} else {
Href("/".to_owned())
}
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut buf: Vec<u8> = Vec::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
{
buf.push((h << 4) | l);
i += 3;
continue;
}
buf.push(bytes[i]);
i += 1;
}
match String::from_utf8(buf) {
Ok(decoded) => decoded,
Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
}
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn percent_encode_safe(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
if is_safe(byte) {
out.push(byte as char);
} else {
out.push('%');
out.push(upper_hex(byte >> 4));
out.push(upper_hex(byte & 0x0f));
}
}
out
}
fn is_safe(b: u8) -> bool {
matches!(b,
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-' | b'_' | b'.' | b'~'
| b'/' | b'@'
)
}
fn upper_hex(nibble: u8) -> char {
match nibble {
0..=9 => (b'0' + nibble) as char,
10..=15 => (b'A' + nibble - 10) as char,
_ => '0',
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_href_resolves_against_base() {
let h = normalise("https://dav.example.com/", "/dav/cal/user/default/").unwrap();
assert_eq!(h.as_str(), "/dav/cal/user/default/");
}
#[test]
fn absolute_href_strips_scheme_and_host() {
let h = normalise(
"https://dav.example.com/cal/",
"https://dav.example.com/dav/cal/user/default/event-1.ics",
)
.unwrap();
assert_eq!(h.as_str(), "/dav/cal/user/default/event-1.ics");
}
#[test]
fn percent_encoding_canonicalised_to_uppercase_hex() {
let h = normalise("https://x/", "/dav/cal/user%2bwork/event%201.ics").unwrap();
assert_eq!(h.as_str(), "/dav/cal/user%2Bwork/event%201.ics");
}
#[test]
fn at_sign_is_preserved_in_collection_paths() {
let h = normalise("https://x/", "/dav/cal/user@host/default/").unwrap();
assert_eq!(h.as_str(), "/dav/cal/user@host/default/");
}
#[test]
fn trailing_slash_preserved() {
let coll = normalise("https://x/", "/a/b/").unwrap();
let item = normalise("https://x/", "/a/b").unwrap();
assert_eq!(coll.as_str(), "/a/b/");
assert_eq!(item.as_str(), "/a/b");
assert_ne!(coll, item);
}
#[test]
fn href_equality_is_byte_exact_after_normalisation() {
let a = normalise("https://x/", "/Dav/Cal/User/Default/").unwrap();
let b = normalise("https://x/", "/Dav/Cal/User/Default/").unwrap();
assert_eq!(a, b);
}
#[test]
fn last_path_component_decodes() {
let h = Href("/dav/cal/user/event%201.ics".to_owned());
assert_eq!(last_path_component(&h), "event 1.ics");
}
#[test]
fn parent_collection_strips_trailing_slash() {
let h = Href("/dav/cal/user/sub/".to_owned());
assert_eq!(parent_collection(&h).as_str(), "/dav/cal/user/");
}
#[test]
fn parent_collection_of_file_is_parent_dir() {
let h = Href("/dav/cal/user/file.ics".to_owned());
assert_eq!(parent_collection(&h).as_str(), "/dav/cal/user/");
}
#[test]
fn parent_collection_of_root_is_root() {
let h = Href("/".to_owned());
assert_eq!(parent_collection(&h).as_str(), "/");
}
#[test]
fn empty_path_treated_as_root() {
let url = Url::parse("https://dav.example.com").unwrap();
let h = normalise_path(&url);
assert_eq!(h.as_str(), "/");
}
#[test]
fn query_and_fragment_stripped() {
let h = normalise("https://x/", "/dav/cal/?q=1#frag").unwrap();
assert_eq!(h.as_str(), "/dav/cal/");
}
#[test]
fn unicode_path_round_trips_through_utf8_percent_encoding() {
let h = normalise("https://x/", "/dav/cal/caf%C3%A9/").unwrap();
assert_eq!(h.as_str(), "/dav/cal/caf%C3%A9/");
}
}
+12
View File
@@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
pub mod client;
pub mod discover;
pub mod href;
pub mod parse;
pub mod retry;
pub mod xml;
+904
View File
@@ -0,0 +1,904 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io::BufRead;
use quick_xml::NsReader;
use quick_xml::events::Event;
use quick_xml::name::ResolveResult;
use crate::dav::href::{Href, normalise};
use crate::dav::xml::{NS_APPLE_ICAL, NS_CALDAV, NS_CARDDAV, NS_DAV};
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("xml error: {0}")]
Xml(String),
#[error("invalid href {href:?}: {source}")]
Href {
href: String,
#[source]
source: crate::dav::href::HrefError,
},
#[error("missing href in response")]
MissingHref,
}
impl From<quick_xml::Error> for ParseError {
fn from(value: quick_xml::Error) -> Self {
ParseError::Xml(value.to_string())
}
}
#[derive(Debug, Clone, Default)]
pub struct ResourceProps {
pub is_collection: bool,
pub is_calendar: bool,
pub is_addressbook: bool,
pub displayname: Option<String>,
pub current_user_principal: Option<String>,
pub calendar_home_set: Option<String>,
pub addressbook_home_set: Option<String>,
pub etag: Option<String>,
pub content_type: Option<String>,
pub last_modified: Option<String>,
pub creation_date: Option<String>,
pub content_length: Option<u64>,
pub calendar_description: Option<String>,
pub addressbook_description: Option<String>,
pub calendar_color: Option<String>,
pub calendar_order: Option<i64>,
pub calendar_timezone: Option<String>,
pub supported_components: Vec<String>,
pub calendar_data: Option<String>,
pub address_data: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DavResponse {
pub href: Href,
pub status: Option<u16>,
pub props: ResourceProps,
pub propstat_errors: Vec<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NsKind {
Dav,
Caldav,
Carddav,
Apple,
Other,
}
fn classify_ns(ns: &ResolveResult<'_>) -> NsKind {
match ns {
ResolveResult::Bound(prefix) => {
let p = prefix.as_ref();
if p == NS_DAV.as_bytes() {
NsKind::Dav
} else if p == NS_CALDAV.as_bytes() {
NsKind::Caldav
} else if p == NS_CARDDAV.as_bytes() {
NsKind::Carddav
} else if p == NS_APPLE_ICAL.as_bytes() {
NsKind::Apple
} else {
NsKind::Other
}
}
_ => NsKind::Other,
}
}
fn is(ns: NsKind, local: &[u8], expected_ns: NsKind, target: &[u8]) -> bool {
ns == expected_ns && local.eq_ignore_ascii_case(target)
}
#[derive(Debug)]
enum Step {
StartElement {
ns: NsKind,
local: Vec<u8>,
attrs: Vec<(Vec<u8>, Vec<u8>)>,
},
EmptyElement {
ns: NsKind,
local: Vec<u8>,
attrs: Vec<(Vec<u8>, Vec<u8>)>,
},
EndElement {
ns: NsKind,
local: Vec<u8>,
},
Text(String),
CData(String),
Other,
Eof,
}
fn next_step<R: BufRead>(xml: &mut NsReader<R>, buf: &mut Vec<u8>) -> Result<Step, ParseError> {
buf.clear();
let (ns, ev) = xml.read_resolved_event_into(buf)?;
let ns_kind = classify_ns(&ns);
Ok(match ev {
Event::Start(e) => {
let local = e.local_name().as_ref().to_vec();
let mut attrs: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for a in e.attributes().flatten() {
attrs.push((a.key.as_ref().to_vec(), a.value.as_ref().to_vec()));
}
Step::StartElement {
ns: ns_kind,
local,
attrs,
}
}
Event::Empty(e) => {
let local = e.local_name().as_ref().to_vec();
let mut attrs: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for a in e.attributes().flatten() {
attrs.push((a.key.as_ref().to_vec(), a.value.as_ref().to_vec()));
}
Step::EmptyElement {
ns: ns_kind,
local,
attrs,
}
}
Event::End(e) => Step::EndElement {
ns: ns_kind,
local: e.local_name().as_ref().to_vec(),
},
Event::Text(t) => {
let s = t.decode().map_err(|e| ParseError::Xml(e.to_string()))?;
Step::Text(s.into_owned())
}
Event::CData(cd) => Step::CData(String::from_utf8_lossy(cd.as_ref()).into_owned()),
Event::Eof => Step::Eof,
_ => Step::Other,
})
}
pub fn parse_multistatus<R: BufRead>(
reader: R,
base_url: &str,
) -> Result<Vec<DavResponse>, ParseError> {
let mut xml = NsReader::from_reader(reader);
let mut buf = Vec::new();
let mut out: Vec<DavResponse> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
loop {
let step = next_step(&mut xml, &mut buf)?;
match step {
Step::StartElement { ns, local, .. } if is(ns, &local, NsKind::Dav, b"response") => {
if let Some(r) = parse_response(&mut xml, base_url)?
&& seen.insert(r.href.as_str().to_owned())
{
out.push(r);
}
}
Step::Eof => break,
_ => {}
}
}
Ok(out)
}
fn parse_response<R: BufRead>(
xml: &mut NsReader<R>,
base_url: &str,
) -> Result<Option<DavResponse>, ParseError> {
let mut buf = Vec::new();
let mut href: Option<String> = None;
let mut status: Option<u16> = None;
let mut props = ResourceProps::default();
let mut propstat_errors: Vec<u16> = Vec::new();
loop {
let step = next_step(xml, &mut buf)?;
match step {
Step::StartElement { ns, local, .. } => {
if is(ns, &local, NsKind::Dav, b"href") {
let text = read_token(xml)?;
if href.is_none() {
href = Some(text);
}
} else if is(ns, &local, NsKind::Dav, b"status") {
let text = read_token(xml)?;
status = parse_http_status(&text);
} else if is(ns, &local, NsKind::Dav, b"propstat") {
let (block_status, block_props) = parse_propstat(xml)?;
match block_status {
Some(s) if (200..300).contains(&s) => merge_props(&mut props, block_props),
Some(s) => propstat_errors.push(s),
None => {}
}
} else {
skip_element(xml, &local)?;
}
}
Step::EndElement { ns, local } if is(ns, &local, NsKind::Dav, b"response") => break,
Step::Eof => return Err(ParseError::Xml("unexpected EOF in <response>".into())),
_ => {}
}
}
let raw_href = href.ok_or(ParseError::MissingHref)?;
let normalised = normalise(base_url, &raw_href).map_err(|e| ParseError::Href {
href: raw_href,
source: e,
})?;
Ok(Some(DavResponse {
href: normalised,
status,
props,
propstat_errors,
}))
}
fn parse_propstat<R: BufRead>(
xml: &mut NsReader<R>,
) -> Result<(Option<u16>, ResourceProps), ParseError> {
let mut buf = Vec::new();
let mut status: Option<u16> = None;
let mut props = ResourceProps::default();
loop {
let step = next_step(xml, &mut buf)?;
match step {
Step::StartElement { ns, local, .. } => {
if is(ns, &local, NsKind::Dav, b"status") {
let text = read_token(xml)?;
status = parse_http_status(&text);
} else if is(ns, &local, NsKind::Dav, b"prop") {
parse_prop_block(xml, &mut props)?;
} else {
skip_element(xml, &local)?;
}
}
Step::EndElement { ns, local } if is(ns, &local, NsKind::Dav, b"propstat") => break,
Step::Eof => return Err(ParseError::Xml("unexpected EOF in <propstat>".into())),
_ => {}
}
}
Ok((status, props))
}
fn parse_prop_block<R: BufRead>(
xml: &mut NsReader<R>,
props: &mut ResourceProps,
) -> Result<(), ParseError> {
let mut buf = Vec::new();
loop {
let step = next_step(xml, &mut buf)?;
match step {
Step::StartElement { ns, local, .. } => {
handle_prop_element(xml, props, ns, &local)?;
}
Step::EmptyElement { .. } => {}
Step::EndElement { ns, local } if is(ns, &local, NsKind::Dav, b"prop") => break,
Step::Eof => return Err(ParseError::Xml("unexpected EOF in <prop>".into())),
_ => {}
}
}
Ok(())
}
fn handle_prop_element<R: BufRead>(
xml: &mut NsReader<R>,
props: &mut ResourceProps,
ns: NsKind,
local: &[u8],
) -> Result<(), ParseError> {
if is(ns, local, NsKind::Dav, b"resourcetype") {
consume_resourcetype(xml, props)?;
} else if is(ns, local, NsKind::Dav, b"displayname") {
props.displayname = Some(read_token(xml)?);
} else if is(ns, local, NsKind::Dav, b"current-user-principal") {
props.current_user_principal = read_first_href(xml, b"current-user-principal")?;
} else if is(ns, local, NsKind::Caldav, b"calendar-home-set") {
props.calendar_home_set = read_first_href(xml, b"calendar-home-set")?;
} else if is(ns, local, NsKind::Carddav, b"addressbook-home-set") {
props.addressbook_home_set = read_first_href(xml, b"addressbook-home-set")?;
} else if is(ns, local, NsKind::Dav, b"getetag") {
let raw = read_token(xml)?;
if !raw.is_empty() {
props.etag = Some(raw);
}
} else if is(ns, local, NsKind::Dav, b"getcontenttype") {
props.content_type = Some(read_token(xml)?);
} else if is(ns, local, NsKind::Dav, b"getlastmodified") {
props.last_modified = Some(read_token(xml)?);
} else if is(ns, local, NsKind::Dav, b"creationdate") {
props.creation_date = Some(read_token(xml)?);
} else if is(ns, local, NsKind::Dav, b"getcontentlength") {
let raw = read_token(xml)?;
props.content_length = raw.parse::<u64>().ok();
} else if is(ns, local, NsKind::Caldav, b"calendar-description") {
props.calendar_description = Some(read_token(xml)?);
} else if is(ns, local, NsKind::Carddav, b"addressbook-description") {
props.addressbook_description = Some(read_token(xml)?);
} else if is(ns, local, NsKind::Apple, b"calendar-color") {
props.calendar_color = Some(read_token(xml)?);
} else if is(ns, local, NsKind::Apple, b"calendar-order") {
let raw = read_token(xml)?;
props.calendar_order = raw.parse::<i64>().ok();
} else if is(ns, local, NsKind::Caldav, b"calendar-timezone") {
props.calendar_timezone = Some(read_token(xml)?);
} else if is(
ns,
local,
NsKind::Caldav,
b"supported-calendar-component-set",
) {
consume_supported_components(xml, props)?;
} else if is(ns, local, NsKind::Caldav, b"calendar-data") {
props.calendar_data = Some(read_text(xml)?);
} else if is(ns, local, NsKind::Carddav, b"address-data") {
props.address_data = Some(read_text(xml)?);
} else {
skip_element(xml, local)?;
}
Ok(())
}
fn consume_resourcetype<R: BufRead>(
xml: &mut NsReader<R>,
props: &mut ResourceProps,
) -> Result<(), ParseError> {
let mut buf = Vec::new();
loop {
let step = next_step(xml, &mut buf)?;
match step {
Step::StartElement { ns, local, .. } => {
mark_resourcetype(ns, &local, props);
skip_element(xml, &local)?;
}
Step::EmptyElement { ns, local, .. } => {
mark_resourcetype(ns, &local, props);
}
Step::EndElement { ns, local } if is(ns, &local, NsKind::Dav, b"resourcetype") => break,
Step::Eof => {
return Err(ParseError::Xml("unexpected EOF in <resourcetype>".into()));
}
_ => {}
}
}
Ok(())
}
fn mark_resourcetype(ns: NsKind, local: &[u8], props: &mut ResourceProps) {
if is(ns, local, NsKind::Dav, b"collection") {
props.is_collection = true;
} else if is(ns, local, NsKind::Caldav, b"calendar") {
props.is_calendar = true;
} else if is(ns, local, NsKind::Carddav, b"addressbook") {
props.is_addressbook = true;
}
}
fn consume_supported_components<R: BufRead>(
xml: &mut NsReader<R>,
props: &mut ResourceProps,
) -> Result<(), ParseError> {
let mut buf = Vec::new();
loop {
let step = next_step(xml, &mut buf)?;
match step {
Step::StartElement {
ns,
local,
ref attrs,
} if is(ns, &local, NsKind::Caldav, b"comp") => {
if let Some(name) = attr_lookup(attrs, b"name") {
props.supported_components.push(name);
}
skip_element(xml, &local)?;
}
Step::EmptyElement {
ns,
local,
ref attrs,
} if is(ns, &local, NsKind::Caldav, b"comp") => {
if let Some(name) = attr_lookup(attrs, b"name") {
props.supported_components.push(name);
}
}
Step::EndElement { ns, local }
if is(
ns,
&local,
NsKind::Caldav,
b"supported-calendar-component-set",
) =>
{
break;
}
Step::Eof => {
return Err(ParseError::Xml(
"unexpected EOF in <supported-calendar-component-set>".into(),
));
}
_ => {}
}
}
Ok(())
}
fn attr_lookup(attrs: &[(Vec<u8>, Vec<u8>)], key: &[u8]) -> Option<String> {
for (k, v) in attrs {
if k == key {
return Some(String::from_utf8_lossy(v).into_owned());
}
}
None
}
fn read_text<R: BufRead>(xml: &mut NsReader<R>) -> Result<String, ParseError> {
let mut buf = Vec::new();
let mut out = String::new();
loop {
let step = next_step(xml, &mut buf)?;
match step {
Step::Text(s) | Step::CData(s) => out.push_str(&s),
Step::StartElement { local, .. } => skip_element(xml, &local)?,
Step::EndElement { .. } | Step::EmptyElement { .. } => break,
Step::Eof => return Err(ParseError::Xml("unexpected EOF reading text".into())),
_ => {}
}
}
Ok(out)
}
fn read_token<R: BufRead>(xml: &mut NsReader<R>) -> Result<String, ParseError> {
Ok(read_text(xml)?.trim().to_owned())
}
fn read_first_href<R: BufRead>(
xml: &mut NsReader<R>,
closing_local: &[u8],
) -> Result<Option<String>, ParseError> {
let mut buf = Vec::new();
let mut out: Option<String> = None;
loop {
let step = next_step(xml, &mut buf)?;
match step {
Step::StartElement { ns, local, .. } => {
if out.is_none() && is(ns, &local, NsKind::Dav, b"href") {
out = Some(read_token(xml)?);
} else {
skip_element(xml, &local)?;
}
}
Step::EmptyElement { .. } => {}
Step::EndElement { local, .. } if local.eq_ignore_ascii_case(closing_local) => {
break;
}
Step::Eof => return Err(ParseError::Xml("unexpected EOF reading href".into())),
_ => {}
}
}
Ok(out)
}
fn skip_element<R: BufRead>(xml: &mut NsReader<R>, target: &[u8]) -> Result<(), ParseError> {
let mut buf = Vec::new();
let mut depth: i32 = 1;
while depth > 0 {
let step = next_step(xml, &mut buf)?;
match step {
Step::StartElement { .. } => depth += 1,
Step::EndElement { local, .. } => {
depth -= 1;
if depth == 0 && !local.eq_ignore_ascii_case(target) {
return Ok(());
}
}
Step::Eof => return Err(ParseError::Xml("unexpected EOF in skip".into())),
_ => {}
}
}
Ok(())
}
fn merge_props(target: &mut ResourceProps, src: ResourceProps) {
if src.is_collection {
target.is_collection = true;
}
if src.is_calendar {
target.is_calendar = true;
}
if src.is_addressbook {
target.is_addressbook = true;
}
overwrite_some(&mut target.displayname, src.displayname);
overwrite_some(
&mut target.current_user_principal,
src.current_user_principal,
);
overwrite_some(&mut target.calendar_home_set, src.calendar_home_set);
overwrite_some(&mut target.addressbook_home_set, src.addressbook_home_set);
overwrite_some(&mut target.etag, src.etag);
overwrite_some(&mut target.content_type, src.content_type);
overwrite_some(&mut target.last_modified, src.last_modified);
overwrite_some(&mut target.creation_date, src.creation_date);
overwrite_some(&mut target.content_length, src.content_length);
overwrite_some(&mut target.calendar_description, src.calendar_description);
overwrite_some(
&mut target.addressbook_description,
src.addressbook_description,
);
overwrite_some(&mut target.calendar_color, src.calendar_color);
overwrite_some(&mut target.calendar_order, src.calendar_order);
overwrite_some(&mut target.calendar_timezone, src.calendar_timezone);
overwrite_some(&mut target.calendar_data, src.calendar_data);
overwrite_some(&mut target.address_data, src.address_data);
if !src.supported_components.is_empty() {
target.supported_components = src.supported_components;
}
}
fn overwrite_some<T>(target: &mut Option<T>, src: Option<T>) {
if src.is_some() {
*target = src;
}
}
fn parse_http_status(text: &str) -> Option<u16> {
let mut parts = text.split_ascii_whitespace();
let _version = parts.next()?;
let code = parts.next()?;
code.parse::<u16>().ok()
}
pub fn strip_ascii_control_chars(body: &[u8]) -> std::borrow::Cow<'_, [u8]> {
if body.iter().any(|&b| is_xml_illegal_control(b)) {
let cleaned: Vec<u8> = body
.iter()
.copied()
.filter(|b| !is_xml_illegal_control(*b))
.collect();
std::borrow::Cow::Owned(cleaned)
} else {
std::borrow::Cow::Borrowed(body)
}
}
pub fn is_xml_illegal_control(b: u8) -> bool {
matches!(b, 0x00..=0x08 | 0x0b | 0x0c | 0x0e..=0x1f)
}
pub struct ControlStrippingReader<R: std::io::Read> {
inner: R,
}
impl<R: std::io::Read> ControlStrippingReader<R> {
pub fn new(inner: R) -> Self {
Self { inner }
}
}
impl<R: std::io::Read> std::io::Read for ControlStrippingReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
loop {
let n = self.inner.read(buf)?;
if n == 0 {
return Ok(0);
}
let mut write = 0;
for read in 0..n {
let b = buf[read];
if !is_xml_illegal_control(b) {
buf[write] = b;
write += 1;
}
}
if write > 0 {
return Ok(write);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn parse(body: &str) -> Vec<DavResponse> {
parse_multistatus(Cursor::new(body), "https://x/").expect("parse")
}
#[test]
fn parses_simple_principal_response() {
let body = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/</d:href>
<d:propstat>
<d:prop>
<d:current-user-principal>
<d:href>/dav/principals/alice/</d:href>
</d:current-user-principal>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let r = parse(body);
assert_eq!(r.len(), 1);
assert_eq!(
r[0].props.current_user_principal.as_deref(),
Some("/dav/principals/alice/")
);
}
#[test]
fn parses_calendar_collection_with_apple_color_and_order() {
let body = r#"<?xml version="1.0"?>
<multistatus xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/">
<response>
<href>/dav/cal/u/default/</href>
<propstat>
<prop>
<resourcetype><collection/><c:calendar/></resourcetype>
<displayname>Default</displayname>
<ic:calendar-color>#3366cc</ic:calendar-color>
<ic:calendar-order>5</ic:calendar-order>
<c:calendar-description>Work</c:calendar-description>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>"#;
let r = parse(body);
assert_eq!(r.len(), 1);
assert!(r[0].props.is_collection);
assert!(r[0].props.is_calendar);
assert_eq!(r[0].props.displayname.as_deref(), Some("Default"));
assert_eq!(r[0].props.calendar_color.as_deref(), Some("#3366cc"));
assert_eq!(r[0].props.calendar_order, Some(5));
assert_eq!(r[0].props.calendar_description.as_deref(), Some("Work"));
}
#[test]
fn parses_item_etag_and_content_type() {
let body = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/cal/u/d/event1.ics</d:href>
<d:propstat>
<d:prop>
<d:resourcetype/>
<d:getetag>"abc123"</d:getetag>
<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let r = parse(body);
assert_eq!(r[0].props.etag.as_deref(), Some("\"abc123\""));
assert!(!r[0].props.is_collection);
assert!(
r[0].props
.content_type
.as_deref()
.unwrap()
.starts_with("text/calendar")
);
}
#[test]
fn parses_calendar_multiget_response_with_data() {
let body = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/dav/cal/u/d/event1.ics</d:href>
<d:propstat>
<d:prop>
<d:getetag>"v2"</d:getetag>
<c:calendar-data>BEGIN:VCALENDAR
END:VCALENDAR</c:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let r = parse(body);
assert_eq!(r[0].props.etag.as_deref(), Some("\"v2\""));
let data = r[0].props.calendar_data.as_deref().unwrap();
assert!(data.contains("VCALENDAR"));
}
#[test]
fn drops_duplicate_response_hrefs() {
let body = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/cal/u/d/event.ics</d:href>
<d:propstat>
<d:prop><d:getetag>"a"</d:getetag></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav/cal/u/d/event.ics</d:href>
<d:propstat>
<d:prop><d:getetag>"a"</d:getetag></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let r = parse(body);
assert_eq!(r.len(), 1);
}
#[test]
fn captures_response_level_status_for_vanished_items() {
let body = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/cal/u/d/gone.ics</d:href>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:response>
</d:multistatus>"#;
let r = parse(body);
assert_eq!(r[0].status, Some(404));
}
#[test]
fn collects_propstat_errors_separately() {
let body = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/cal/u/d/event.ics</d:href>
<d:propstat>
<d:prop><d:getetag>"a"</d:getetag></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop><d:custom-prop/></d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let r = parse(body);
assert_eq!(r[0].propstat_errors, vec![404]);
assert_eq!(r[0].props.etag.as_deref(), Some("\"a\""));
}
#[test]
fn unknown_namespace_elements_ignored() {
let body = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
<d:response>
<d:href>/dav/cal/u/d/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/></d:resourcetype>
<cs:getctag>abc</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let r = parse(body);
assert!(r[0].props.is_collection);
}
#[test]
fn strip_control_chars_removes_low_bytes() {
let raw = b"<?xml version=\"1.0\"?>\x01<root/>";
let cleaned = strip_ascii_control_chars(raw);
assert!(!cleaned.contains(&0x01));
}
#[test]
fn strip_control_chars_leaves_tab_newline_cr() {
let raw = b"<?xml ?>\t\n\r";
let cleaned = strip_ascii_control_chars(raw);
match cleaned {
std::borrow::Cow::Borrowed(_) => {}
std::borrow::Cow::Owned(_) => panic!("should not have allocated"),
}
}
#[test]
fn parses_addressbook_collection() {
let body = r#"<?xml version="1.0"?>
<multistatus xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
<response>
<href>/dav/card/u/default/</href>
<propstat>
<prop>
<resourcetype><collection/><c:addressbook/></resourcetype>
<displayname>Contacts</displayname>
<c:addressbook-description>Personal</c:addressbook-description>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>"#;
let r = parse(body);
assert!(r[0].props.is_addressbook);
assert_eq!(r[0].props.displayname.as_deref(), Some("Contacts"));
assert_eq!(
r[0].props.addressbook_description.as_deref(),
Some("Personal")
);
}
#[test]
fn empty_multistatus_yields_no_responses() {
let body = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:"/>"#;
let r = parse(body);
assert!(r.is_empty());
}
#[test]
fn supported_calendar_component_set_captures_empty_element_name_attr() {
let body = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/dav/cal/u/d/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
<c:supported-calendar-component-set>
<c:comp name="VEVENT"/>
<c:comp name="VTODO"/>
</c:supported-calendar-component-set>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let r = parse(body);
assert_eq!(r[0].props.supported_components, vec!["VEVENT", "VTODO"]);
}
#[test]
fn merge_props_last_propstat_wins_for_duplicate_keys() {
let body = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/cal/u/d/</d:href>
<d:propstat>
<d:prop><d:displayname>First</d:displayname></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop><d:displayname>Second</d:displayname></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let r = parse(body);
assert_eq!(r[0].props.displayname.as_deref(), Some("Second"));
}
#[test]
fn content_length_parses_to_u64() {
let body = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/file.bin</d:href>
<d:propstat>
<d:prop>
<d:getcontentlength>123456</d:getcontentlength>
<d:resourcetype/>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let r = parse(body);
assert_eq!(r[0].props.content_length, Some(123456));
}
}
+116
View File
@@ -0,0 +1,116 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use serde_json::Value;
use crate::jmap::retry::Disposition;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DavOutcome {
Success,
Retryable,
Vanished,
Auth,
Fatal,
}
pub fn classify(status: u16, body: &[u8]) -> DavOutcome {
if (200..300).contains(&status) {
return DavOutcome::Success;
}
if status == 401 {
return DavOutcome::Auth;
}
if status == 403 {
if is_google_usage_limit(body) {
return DavOutcome::Retryable;
}
return DavOutcome::Auth;
}
if status == 404 || status == 410 {
return DavOutcome::Vanished;
}
if matches!(status, 423 | 429 | 502 | 503 | 504 | 507) {
return DavOutcome::Retryable;
}
match crate::jmap::retry::classify_http_status(status) {
Disposition::Retryable => DavOutcome::Retryable,
Disposition::Fatal => DavOutcome::Fatal,
}
}
fn is_google_usage_limit(body: &[u8]) -> bool {
let value: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(_) => return false,
};
let errors = value
.get("error")
.and_then(|e| e.get("errors"))
.and_then(Value::as_array);
let Some(errors) = errors else {
return false;
};
errors.iter().any(|entry| {
entry
.get("domain")
.and_then(Value::as_str)
.map(|d| d == "usageLimits")
.unwrap_or(false)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn success_range() {
assert_eq!(classify(200, b""), DavOutcome::Success);
assert_eq!(classify(207, b""), DavOutcome::Success);
assert_eq!(classify(299, b""), DavOutcome::Success);
}
#[test]
fn auth_status() {
assert_eq!(classify(401, b""), DavOutcome::Auth);
assert_eq!(classify(403, b""), DavOutcome::Auth);
}
#[test]
fn vanished_status() {
assert_eq!(classify(404, b""), DavOutcome::Vanished);
assert_eq!(classify(410, b""), DavOutcome::Vanished);
}
#[test]
fn locked_and_quota_are_retryable() {
assert_eq!(classify(423, b""), DavOutcome::Retryable);
assert_eq!(classify(429, b""), DavOutcome::Retryable);
assert_eq!(classify(503, b""), DavOutcome::Retryable);
assert_eq!(classify(507, b""), DavOutcome::Retryable);
}
#[test]
fn google_usage_limit_403_is_retryable() {
let body =
br#"{"error":{"errors":[{"domain":"usageLimits","reason":"rateLimitExceeded"}]}}"#;
assert_eq!(classify(403, body), DavOutcome::Retryable);
}
#[test]
fn non_google_403_is_auth() {
let body = br#"{"error":{"errors":[{"domain":"global","reason":"forbidden"}]}}"#;
assert_eq!(classify(403, body), DavOutcome::Auth);
}
#[test]
fn fatal_4xx_other() {
assert_eq!(classify(400, b""), DavOutcome::Fatal);
assert_eq!(classify(405, b""), DavOutcome::Fatal);
assert_eq!(classify(412, b""), DavOutcome::Fatal);
}
}
+251
View File
@@ -0,0 +1,251 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use crate::dav::href::Href;
pub const NS_DAV: &str = "DAV:";
pub const NS_CALDAV: &str = "urn:ietf:params:xml:ns:caldav";
pub const NS_CARDDAV: &str = "urn:ietf:params:xml:ns:carddav";
pub const NS_APPLE_ICAL: &str = "http://apple.com/ns/ical/";
pub fn propfind_current_user_principal() -> String {
String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<d:propfind xmlns:d=\"DAV:\">\
<d:prop><d:current-user-principal/></d:prop>\
</d:propfind>",
)
}
pub fn propfind_calendar_home_set() -> String {
String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\">\
<d:prop><c:calendar-home-set/></d:prop>\
</d:propfind>",
)
}
pub fn propfind_addressbook_home_set() -> String {
String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:carddav\">\
<d:prop><c:addressbook-home-set/></d:prop>\
</d:propfind>",
)
}
pub fn propfind_principal_and_calendar_home_set() -> String {
String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\">\
<d:prop>\
<d:current-user-principal/>\
<c:calendar-home-set/>\
</d:prop>\
</d:propfind>",
)
}
pub fn propfind_principal_and_addressbook_home_set() -> String {
String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:carddav\">\
<d:prop>\
<d:current-user-principal/>\
<c:addressbook-home-set/>\
</d:prop>\
</d:propfind>",
)
}
pub fn propfind_calendar_collections() -> String {
String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<d:propfind \
xmlns:d=\"DAV:\" \
xmlns:c=\"urn:ietf:params:xml:ns:caldav\" \
xmlns:ic=\"http://apple.com/ns/ical/\">\
<d:prop>\
<d:resourcetype/>\
<d:displayname/>\
<c:calendar-description/>\
<c:supported-calendar-component-set/>\
<c:calendar-timezone/>\
<ic:calendar-color/>\
<ic:calendar-order/>\
</d:prop>\
</d:propfind>",
)
}
pub fn propfind_addressbook_collections() -> String {
String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<d:propfind \
xmlns:d=\"DAV:\" \
xmlns:c=\"urn:ietf:params:xml:ns:carddav\">\
<d:prop>\
<d:resourcetype/>\
<d:displayname/>\
<c:addressbook-description/>\
</d:prop>\
</d:propfind>",
)
}
pub fn propfind_webdav_listing() -> String {
String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<d:propfind xmlns:d=\"DAV:\">\
<d:prop>\
<d:resourcetype/>\
<d:displayname/>\
<d:getetag/>\
<d:getcontenttype/>\
<d:getlastmodified/>\
<d:getcontentlength/>\
<d:creationdate/>\
</d:prop>\
</d:propfind>",
)
}
pub fn propfind_dav_items() -> String {
String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<d:propfind xmlns:d=\"DAV:\">\
<d:prop>\
<d:resourcetype/>\
<d:getetag/>\
<d:getcontenttype/>\
<d:getlastmodified/>\
</d:prop>\
</d:propfind>",
)
}
pub fn calendar_multiget(hrefs: &[Href]) -> String {
let mut out = String::with_capacity(256 + hrefs.len() * 64);
out.push_str(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<c:calendar-multiget \
xmlns:d=\"DAV:\" \
xmlns:c=\"urn:ietf:params:xml:ns:caldav\">\
<d:prop>\
<d:getetag/>\
<c:calendar-data/>\
</d:prop>",
);
for h in hrefs {
write_href_element(&mut out, h);
}
out.push_str("</c:calendar-multiget>");
out
}
pub fn addressbook_multiget(hrefs: &[Href]) -> String {
let mut out = String::with_capacity(256 + hrefs.len() * 64);
out.push_str(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<c:addressbook-multiget \
xmlns:d=\"DAV:\" \
xmlns:c=\"urn:ietf:params:xml:ns:carddav\">\
<d:prop>\
<d:getetag/>\
<c:address-data/>\
</d:prop>",
);
for h in hrefs {
write_href_element(&mut out, h);
}
out.push_str("</c:addressbook-multiget>");
out
}
fn write_href_element(out: &mut String, h: &Href) {
out.push_str("<d:href>");
write_escaped(out, h.as_str());
out.push_str("</d:href>");
}
fn write_escaped(out: &mut String, s: &str) {
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(ch),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dav::href::Href;
#[test]
fn principal_body_matches_expected_shape() {
let body = propfind_current_user_principal();
assert!(body.starts_with("<?xml"));
assert!(body.contains("<d:current-user-principal/>"));
assert!(body.contains("xmlns:d=\"DAV:\""));
}
#[test]
fn calendar_home_set_includes_caldav_namespace() {
let body = propfind_calendar_home_set();
assert!(body.contains("xmlns:c=\"urn:ietf:params:xml:ns:caldav\""));
assert!(body.contains("<c:calendar-home-set/>"));
}
#[test]
fn addressbook_home_set_includes_carddav_namespace() {
let body = propfind_addressbook_home_set();
assert!(body.contains("xmlns:c=\"urn:ietf:params:xml:ns:carddav\""));
assert!(body.contains("<c:addressbook-home-set/>"));
}
#[test]
fn calendar_multiget_lists_hrefs_in_order() {
let hrefs = vec![
Href::from_normalised("/dav/cal/u/d/a.ics".to_owned()),
Href::from_normalised("/dav/cal/u/d/b.ics".to_owned()),
];
let body = calendar_multiget(&hrefs);
let a_pos = body.find("/a.ics").unwrap();
let b_pos = body.find("/b.ics").unwrap();
assert!(a_pos < b_pos);
assert!(body.contains("<c:calendar-data/>"));
}
#[test]
fn addressbook_multiget_emits_address_data_element() {
let hrefs = vec![Href::from_normalised("/dav/card/u/d/a.vcf".to_owned())];
let body = addressbook_multiget(&hrefs);
assert!(body.contains("<c:address-data/>"));
assert!(body.contains("<d:href>/dav/card/u/d/a.vcf</d:href>"));
}
#[test]
fn escape_handles_xml_metacharacters() {
let h = Href::from_normalised("/dav/cal/u/d/&lt;weird&gt;.ics".to_owned());
let body = calendar_multiget(&[h]);
assert!(body.contains("&amp;lt;weird&amp;gt;.ics"));
}
#[test]
fn webdav_listing_requests_filesystem_props() {
let body = propfind_webdav_listing();
assert!(body.contains("<d:getcontenttype/>"));
assert!(body.contains("<d:getlastmodified/>"));
assert!(body.contains("<d:getcontentlength/>"));
assert!(body.contains("<d:creationdate/>"));
}
}
+111
View File
@@ -0,0 +1,111 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use rusqlite::{Connection, OptionalExtension, params};
pub fn intern_blob(conn: &Connection, bytes: &[u8]) -> Result<i64, rusqlite::Error> {
let hash = blake3::hash(bytes);
let hash_bytes = hash.as_bytes().as_slice();
conn.execute(
"INSERT OR IGNORE INTO blobs (hash, data) VALUES (?1, ?2)",
params![hash_bytes, bytes],
)?;
conn.query_row(
"SELECT id FROM blobs WHERE hash = ?1",
params![hash_bytes],
|row| row.get(0),
)
}
pub fn blob_bytes(conn: &Connection, id: i64) -> Result<Option<Vec<u8>>, rusqlite::Error> {
conn.query_row("SELECT data FROM blobs WHERE id = ?1", params![id], |row| {
row.get(0)
})
.optional()
}
pub fn gc_orphan_blobs(conn: &Connection) -> Result<usize, rusqlite::Error> {
conn.execute(
"DELETE FROM blobs WHERE id NOT IN (
SELECT blob_id FROM emails
UNION SELECT blob_id FROM sieve_scripts
UNION SELECT blob_id FROM file_nodes WHERE blob_id IS NOT NULL
UNION SELECT json_tree.atom FROM contact_cards, json_tree(contact_cards.data)
WHERE json_tree.key = '@blob'
UNION SELECT json_tree.atom FROM calendar_events, json_tree(calendar_events.data)
WHERE json_tree.key = '@blob')",
[],
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
c
}
#[test]
fn intern_dedups_identical_bytes() {
let c = mem();
let a = intern_blob(&c, b"hello world").unwrap();
let b = intern_blob(&c, b"hello world").unwrap();
let other = intern_blob(&c, b"different").unwrap();
assert_eq!(a, b);
assert_ne!(a, other);
let n: i64 = c
.query_row("SELECT count(*) FROM blobs", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 2);
}
#[test]
fn blob_bytes_roundtrip() {
let c = mem();
let id = intern_blob(&c, b"payload").unwrap();
assert_eq!(
blob_bytes(&c, id).unwrap().as_deref(),
Some(&b"payload"[..])
);
assert_eq!(blob_bytes(&c, 9999).unwrap(), None);
}
#[test]
fn gc_keeps_referenced_and_reaps_orphans() {
let c = mem();
let referenced = intern_blob(&c, b"keep me").unwrap();
let _orphan = intern_blob(&c, b"reap me").unwrap();
c.execute(
"INSERT INTO emails (blob_id, received_at, mailbox_ids, keywords)
VALUES (?1, '2020-01-01T00:00:00Z', '[1]', '[]')",
params![referenced],
)
.unwrap();
let removed = gc_orphan_blobs(&c).unwrap();
assert_eq!(removed, 1);
assert!(blob_bytes(&c, referenced).unwrap().is_some());
}
#[test]
fn gc_follows_blob_sentinel_in_json_data() {
let c = mem();
let photo = intern_blob(&c, b"photo bytes").unwrap();
let _orphan = intern_blob(&c, b"orphan").unwrap();
c.execute(
"INSERT INTO contact_cards (uid, address_book_ids, data)
VALUES ('u1', '[1]', ?1)",
params![format!(r#"{{"photos":{{"p":{{"@blob":{photo}}}}}}}"#)],
)
.unwrap();
let removed = gc_orphan_blobs(&c).unwrap();
assert_eq!(removed, 1);
assert!(blob_bytes(&c, photo).unwrap().is_some());
}
}
+336
View File
@@ -0,0 +1,336 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
pub const CALENDAR: &str = "calendar";
pub const CALENDAR_EVENT: &str = "calendarevent";
pub const ADDRESS_BOOK: &str = "addressbook";
pub const CONTACT_CARD: &str = "contactcard";
pub const FILE_NODE: &str = "filenode";
#[derive(Debug, Clone)]
pub struct ItemRow {
pub item_href: String,
pub etag: String,
pub local_id: i64,
}
pub fn insert(
conn: &Connection,
source_id: i64,
type_name: &str,
collection_href: &str,
item_href: &str,
etag: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_dav
(source_id, type_name, collection_href, item_href, etag, local_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
source_id,
type_name,
collection_href,
item_href,
etag,
local_id
],
)?;
Ok(())
}
pub fn update_etag(
conn: &Connection,
source_id: i64,
type_name: &str,
item_href: &str,
etag: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE sync_id_dav
SET etag = ?4
WHERE source_id = ?1 AND type_name = ?2 AND item_href = ?3",
params![source_id, type_name, item_href, etag],
)?;
Ok(())
}
pub fn local_for_item(
conn: &Connection,
source_id: i64,
type_name: &str,
item_href: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_dav
WHERE source_id = ?1 AND type_name = ?2 AND item_href = ?3",
params![source_id, type_name, item_href],
|row| row.get(0),
)
.optional()
}
pub fn items_in_collection(
conn: &Connection,
source_id: i64,
type_name: &str,
collection_href: &str,
) -> Result<Vec<ItemRow>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT item_href, etag, local_id FROM sync_id_dav
WHERE source_id = ?1 AND type_name = ?2 AND collection_href = ?3",
)?;
let rows = stmt.query_map(params![source_id, type_name, collection_href], |row| {
Ok(ItemRow {
item_href: row.get(0)?,
etag: row.get(1)?,
local_id: row.get(2)?,
})
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
pub fn collections_of_type(
conn: &Connection,
source_id: i64,
type_name: &str,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT item_href, local_id FROM sync_id_dav
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, type_name], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (href, id) = r?;
map.insert(href, id);
}
Ok(map)
}
pub fn delete_item(
conn: &Connection,
source_id: i64,
type_name: &str,
item_href: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_dav
WHERE source_id = ?1 AND type_name = ?2 AND item_href = ?3",
params![source_id, type_name, item_href],
)?;
Ok(())
}
pub fn delete_collection(
conn: &Connection,
source_id: i64,
type_name: &str,
collection_href: &str,
) -> Result<usize, rusqlite::Error> {
let removed = conn.execute(
"DELETE FROM sync_id_dav
WHERE source_id = ?1 AND type_name = ?2
AND (item_href = ?3 OR collection_href = ?3)",
params![source_id, type_name, collection_href],
)?;
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (rusqlite::Connection, i64) {
let conn = rusqlite::Connection::open_in_memory().unwrap();
init::apply_schema(&conn).unwrap();
let key = SourceKey {
kind: "caldav".to_owned(),
session_url: "https://x/".to_owned(),
account_id: "u".to_owned(),
};
let sid = upsert_source(&conn, &key, Some("u"), "u").unwrap();
(conn, sid)
}
#[test]
fn insert_and_lookup_round_trip() {
let (conn, sid) = setup();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e.ics",
"\"v1\"",
7,
)
.unwrap();
let id = local_for_item(&conn, sid, CALENDAR_EVENT, "/cal/a/e.ics")
.unwrap()
.unwrap();
assert_eq!(id, 7);
}
#[test]
fn replace_keeps_local_id_on_etag_change() {
let (conn, sid) = setup();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e.ics",
"\"v1\"",
7,
)
.unwrap();
update_etag(&conn, sid, CALENDAR_EVENT, "/cal/a/e.ics", "\"v2\"").unwrap();
let rows = items_in_collection(&conn, sid, CALENDAR_EVENT, "/cal/a/").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].etag, "\"v2\"");
assert_eq!(rows[0].local_id, 7);
}
#[test]
fn items_in_collection_filters_by_collection_href() {
let (conn, sid) = setup();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e1.ics",
"v1",
1,
)
.unwrap();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e2.ics",
"v2",
2,
)
.unwrap();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/b/",
"/cal/b/e3.ics",
"v3",
3,
)
.unwrap();
let rows = items_in_collection(&conn, sid, CALENDAR_EVENT, "/cal/a/").unwrap();
assert_eq!(rows.len(), 2);
}
#[test]
fn delete_item_removes_only_one_row() {
let (conn, sid) = setup();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e1.ics",
"v",
1,
)
.unwrap();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e2.ics",
"v",
2,
)
.unwrap();
delete_item(&conn, sid, CALENDAR_EVENT, "/cal/a/e1.ics").unwrap();
let rows = items_in_collection(&conn, sid, CALENDAR_EVENT, "/cal/a/").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].item_href, "/cal/a/e2.ics");
}
#[test]
fn delete_collection_removes_collection_row_and_items() {
let (conn, sid) = setup();
insert(&conn, sid, CALENDAR, "/cal/a/", "/cal/a/", "", 10).unwrap();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e.ics",
"v",
1,
)
.unwrap();
let removed = delete_collection(&conn, sid, CALENDAR_EVENT, "/cal/a/").unwrap();
assert_eq!(removed, 1);
let rows = items_in_collection(&conn, sid, CALENDAR_EVENT, "/cal/a/").unwrap();
assert!(rows.is_empty());
}
#[test]
fn collections_of_type_lists_all_known_collections() {
let (conn, sid) = setup();
insert(&conn, sid, CALENDAR, "/cal/a/", "/cal/a/", "", 1).unwrap();
insert(&conn, sid, CALENDAR, "/cal/b/", "/cal/b/", "", 2).unwrap();
let map = collections_of_type(&conn, sid, CALENDAR).unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map.get("/cal/a/"), Some(&1));
assert_eq!(map.get("/cal/b/"), Some(&2));
}
#[test]
fn unique_local_id_per_type_enforced() {
let (conn, sid) = setup();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e1.ics",
"v",
1,
)
.unwrap();
let err = insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e2.ics",
"v",
1,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("UNIQUE"),
"expected UNIQUE violation, got {msg}"
);
}
}
+265
View File
@@ -0,0 +1,265 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
pub const MAILBOX: &str = "mailbox";
pub const CALENDAR: &str = "calendar";
pub const ADDRESS_BOOK: &str = "addressbook";
pub const EMAIL: &str = "email";
pub const CALENDAR_EVENT: &str = "calendarevent";
pub const CONTACT_CARD: &str = "contactcard";
#[derive(Debug, Clone)]
pub struct ItemRow {
pub item_id: String,
pub change_key: String,
pub local_id: i64,
}
#[derive(Debug, Clone)]
pub struct FolderRow {
pub item_id: String,
pub folder_id: String,
pub change_key: String,
pub local_id: i64,
}
pub fn insert(
conn: &Connection,
source_id: i64,
type_name: &str,
folder_id: &str,
item_id: &str,
change_key: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_exchange_ews
(source_id, type_name, folder_id, item_id, change_key, sync_state, local_id)
VALUES (?1, ?2, ?3, ?4, ?5, '', ?6)",
params![
source_id, type_name, folder_id, item_id, change_key, local_id
],
)?;
Ok(())
}
pub fn update_change_key(
conn: &Connection,
source_id: i64,
type_name: &str,
item_id: &str,
change_key: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE sync_id_exchange_ews
SET change_key = ?4
WHERE source_id = ?1 AND type_name = ?2 AND item_id = ?3",
params![source_id, type_name, item_id, change_key],
)?;
Ok(())
}
pub fn delete_item(
conn: &Connection,
source_id: i64,
type_name: &str,
item_id: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_exchange_ews
WHERE source_id = ?1 AND type_name = ?2 AND item_id = ?3",
params![source_id, type_name, item_id],
)?;
Ok(())
}
pub fn local_for_item(
conn: &Connection,
source_id: i64,
type_name: &str,
item_id: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_exchange_ews
WHERE source_id = ?1 AND type_name = ?2 AND item_id = ?3",
params![source_id, type_name, item_id],
|row| row.get(0),
)
.optional()
}
pub fn items_in_folder(
conn: &Connection,
source_id: i64,
type_name: &str,
folder_id: &str,
) -> Result<Vec<ItemRow>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT item_id, change_key, local_id FROM sync_id_exchange_ews
WHERE source_id = ?1 AND type_name = ?2 AND folder_id = ?3",
)?;
let rows = stmt.query_map(params![source_id, type_name, folder_id], |row| {
Ok(ItemRow {
item_id: row.get(0)?,
change_key: row.get(1)?,
local_id: row.get(2)?,
})
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
pub fn folders_of_type(
conn: &Connection,
source_id: i64,
type_name: &str,
) -> Result<HashMap<String, FolderRow>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT item_id, folder_id, change_key, local_id FROM sync_id_exchange_ews
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, type_name], |row| {
Ok(FolderRow {
item_id: row.get(0)?,
folder_id: row.get(1)?,
change_key: row.get(2)?,
local_id: row.get(3)?,
})
})?;
let mut map = HashMap::new();
for r in rows {
let f = r?;
map.insert(f.item_id.clone(), f);
}
Ok(map)
}
pub fn get_sync_state(
conn: &Connection,
source_id: i64,
type_name: &str,
item_id: &str,
) -> Result<Option<String>, rusqlite::Error> {
conn.query_row(
"SELECT sync_state FROM sync_id_exchange_ews
WHERE source_id = ?1 AND type_name = ?2 AND item_id = ?3",
params![source_id, type_name, item_id],
|row| row.get::<_, String>(0),
)
.optional()
}
pub fn set_sync_state(
conn: &Connection,
source_id: i64,
type_name: &str,
item_id: &str,
sync_state: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE sync_id_exchange_ews
SET sync_state = ?4
WHERE source_id = ?1 AND type_name = ?2 AND item_id = ?3",
params![source_id, type_name, item_id, sync_state],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (rusqlite::Connection, i64) {
let conn = rusqlite::Connection::open_in_memory().unwrap();
init::apply_schema(&conn).unwrap();
let key = SourceKey {
kind: "exchange_ews".to_owned(),
session_url: "https://x/EWS/Exchange.asmx".to_owned(),
account_id: "u@d".to_owned(),
};
let sid = upsert_source(&conn, &key, Some("u"), "u@d").unwrap();
(conn, sid)
}
#[test]
fn insert_and_lookup_round_trip() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "FOLDER1", "ITEM1", "CK1", 42).unwrap();
let id = local_for_item(&conn, sid, EMAIL, "ITEM1").unwrap().unwrap();
assert_eq!(id, 42);
let rows = items_in_folder(&conn, sid, EMAIL, "FOLDER1").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].change_key, "CK1");
}
#[test]
fn update_change_key_preserves_local_id() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "F1", "I1", "A", 7).unwrap();
update_change_key(&conn, sid, EMAIL, "I1", "B").unwrap();
let rows = items_in_folder(&conn, sid, EMAIL, "F1").unwrap();
assert_eq!(rows[0].change_key, "B");
assert_eq!(rows[0].local_id, 7);
}
#[test]
fn folders_of_type_lists_by_item_id() {
let (conn, sid) = setup();
insert(&conn, sid, MAILBOX, "", "F1", "CK1", 1).unwrap();
insert(&conn, sid, MAILBOX, "F1", "F2", "CK2", 2).unwrap();
let map = folders_of_type(&conn, sid, MAILBOX).unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map.get("F1").map(|r| r.local_id), Some(1));
assert_eq!(map.get("F2").map(|r| r.local_id), Some(2));
}
#[test]
fn sync_state_round_trips() {
let (conn, sid) = setup();
insert(&conn, sid, MAILBOX, "", "F1", "", 1).unwrap();
assert_eq!(
get_sync_state(&conn, sid, MAILBOX, "F1")
.unwrap()
.as_deref(),
Some("")
);
set_sync_state(&conn, sid, MAILBOX, "F1", "opaque-state").unwrap();
assert_eq!(
get_sync_state(&conn, sid, MAILBOX, "F1")
.unwrap()
.as_deref(),
Some("opaque-state")
);
}
#[test]
fn delete_item_removes_only_one() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "F1", "I1", "A", 1).unwrap();
insert(&conn, sid, EMAIL, "F1", "I2", "A", 2).unwrap();
delete_item(&conn, sid, EMAIL, "I1").unwrap();
let rows = items_in_folder(&conn, sid, EMAIL, "F1").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].item_id, "I2");
}
#[test]
fn unique_local_id_per_type_is_enforced() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "F1", "I1", "A", 1).unwrap();
let err = insert(&conn, sid, EMAIL, "F1", "I2", "A", 1).unwrap_err();
assert!(err.to_string().contains("UNIQUE"));
}
}
+167
View File
@@ -0,0 +1,167 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
pub const MAILBOX: &str = "mailbox";
pub const EMAIL: &str = "email";
pub const CALENDAR: &str = "calendar";
pub const CALENDAR_EVENT: &str = "calendarevent";
pub const ADDRESS_BOOK: &str = "addressbook";
pub const CONTACT_CARD: &str = "contactcard";
#[derive(Debug, Clone)]
pub struct IdRow {
pub graph_id: String,
pub local_id: i64,
}
pub fn insert(
conn: &Connection,
source_id: i64,
type_name: &str,
graph_id: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_exchange_graph (source_id, type_name, graph_id, local_id)
VALUES (?1, ?2, ?3, ?4)",
params![source_id, type_name, graph_id, local_id],
)?;
Ok(())
}
pub fn delete(
conn: &Connection,
source_id: i64,
type_name: &str,
graph_id: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_exchange_graph
WHERE source_id = ?1 AND type_name = ?2 AND graph_id = ?3",
params![source_id, type_name, graph_id],
)?;
Ok(())
}
pub fn local_for_graph_id(
conn: &Connection,
source_id: i64,
type_name: &str,
graph_id: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_exchange_graph
WHERE source_id = ?1 AND type_name = ?2 AND graph_id = ?3",
params![source_id, type_name, graph_id],
|row| row.get(0),
)
.optional()
}
pub fn ids_of_type(
conn: &Connection,
source_id: i64,
type_name: &str,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT graph_id, local_id FROM sync_id_exchange_graph
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, type_name], |row| {
Ok(IdRow {
graph_id: row.get(0)?,
local_id: row.get(1)?,
})
})?;
let mut map = HashMap::new();
for r in rows {
let row = r?;
map.insert(row.graph_id, row.local_id);
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let conn = Connection::open_in_memory().unwrap();
init::apply_schema(&conn).unwrap();
let key = SourceKey {
kind: "exchange_graph".to_owned(),
session_url: "https://x|https://graph.microsoft.com/v1.0".to_owned(),
account_id: "u-uuid".to_owned(),
};
let sid = upsert_source(&conn, &key, Some("u@d"), "u@d").unwrap();
(conn, sid)
}
#[test]
fn insert_and_lookup_round_trip() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "AAkA-base64==", 17).unwrap();
let id = local_for_graph_id(&conn, sid, EMAIL, "AAkA-base64==")
.unwrap()
.unwrap();
assert_eq!(id, 17);
}
#[test]
fn graph_id_is_case_sensitive() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "AaBb", 1).unwrap();
assert!(
local_for_graph_id(&conn, sid, EMAIL, "aabb")
.unwrap()
.is_none()
);
assert!(
local_for_graph_id(&conn, sid, EMAIL, "AaBb")
.unwrap()
.is_some()
);
}
#[test]
fn unique_local_id_per_type_is_enforced() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "ID-A", 1).unwrap();
let err = insert(&conn, sid, EMAIL, "ID-B", 1).unwrap_err();
assert!(err.to_string().contains("UNIQUE"));
}
#[test]
fn ids_of_type_filters_by_type() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "M1", 1).unwrap();
insert(&conn, sid, EMAIL, "M2", 2).unwrap();
insert(&conn, sid, MAILBOX, "F1", 3).unwrap();
let emails = ids_of_type(&conn, sid, EMAIL).unwrap();
let folders = ids_of_type(&conn, sid, MAILBOX).unwrap();
assert_eq!(emails.len(), 2);
assert_eq!(folders.len(), 1);
assert_eq!(emails.get("M1"), Some(&1));
assert_eq!(folders.get("F1"), Some(&3));
}
#[test]
fn delete_removes_only_one() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "M1", 1).unwrap();
insert(&conn, sid, EMAIL, "M2", 2).unwrap();
delete(&conn, sid, EMAIL, "M1").unwrap();
let remaining = ids_of_type(&conn, sid, EMAIL).unwrap();
assert_eq!(remaining.len(), 1);
assert!(remaining.contains_key("M2"));
}
}
+114
View File
@@ -0,0 +1,114 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
use crate::types::ObjectType;
pub fn insert(
conn: &Connection,
source_id: i64,
ty: ObjectType,
jmap_id: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT OR REPLACE INTO sync_id_jmap (source_id, type_name, jmap_id, local_id)
VALUES (?1, ?2, ?3, ?4)",
params![source_id, ty.jmap_name(), jmap_id, local_id],
)?;
Ok(())
}
pub fn local_for(
conn: &Connection,
source_id: i64,
ty: ObjectType,
jmap_id: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_jmap
WHERE source_id = ?1 AND type_name = ?2 AND jmap_id = ?3",
params![source_id, ty.jmap_name(), jmap_id],
|row| row.get(0),
)
.optional()
}
pub fn jmap_to_local(
conn: &Connection,
source_id: i64,
ty: ObjectType,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT jmap_id, local_id FROM sync_id_jmap WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, ty.jmap_name()], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (j, l) = r?;
map.insert(j, l);
}
Ok(map)
}
pub fn delete(
conn: &Connection,
source_id: i64,
ty: ObjectType,
jmap_id: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_jmap
WHERE source_id = ?1 AND type_name = ?2 AND jmap_id = ?3",
params![source_id, ty.jmap_name(), jmap_id],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "jmap".to_owned(),
session_url: "u".to_owned(),
account_id: "w".to_owned(),
},
None,
"alice",
)
.unwrap();
(c, sid)
}
#[test]
fn crud_roundtrip() {
let (c, sid) = setup();
insert(&c, sid, ObjectType::Mailbox, "M1", 1).unwrap();
insert(&c, sid, ObjectType::Mailbox, "M2", 2).unwrap();
assert_eq!(
local_for(&c, sid, ObjectType::Mailbox, "M1").unwrap(),
Some(1)
);
let map = jmap_to_local(&c, sid, ObjectType::Mailbox).unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map.get("M2"), Some(&2));
delete(&c, sid, ObjectType::Mailbox, "M1").unwrap();
assert_eq!(local_for(&c, sid, ObjectType::Mailbox, "M1").unwrap(), None);
}
}
+292
View File
@@ -0,0 +1,292 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::{HashMap, HashSet};
use rusqlite::{Connection, OptionalExtension, params};
const MAILBOX: &str = "mailbox";
const EMAIL: &str = "email";
pub fn insert_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT OR REPLACE INTO sync_id_imap
(source_id, type_name, folder, uidvalidity, uid, local_id)
VALUES (?1, ?2, ?3, 0, 0, ?4)",
params![source_id, MAILBOX, folder, local_id],
)?;
Ok(())
}
pub fn insert_email(
conn: &Connection,
source_id: i64,
folder: &str,
uidvalidity: u32,
uid: u32,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT OR REPLACE INTO sync_id_imap
(source_id, type_name, folder, uidvalidity, uid, local_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![source_id, EMAIL, folder, uidvalidity, uid, local_id],
)?;
Ok(())
}
pub fn local_for_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3
AND uidvalidity = 0 AND uid = 0",
params![source_id, MAILBOX, folder],
|row| row.get(0),
)
.optional()
}
pub fn local_for_email(
conn: &Connection,
source_id: i64,
folder: &str,
uidvalidity: u32,
uid: u32,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2
AND folder = ?3 AND uidvalidity = ?4 AND uid = ?5",
params![source_id, EMAIL, folder, uidvalidity, uid],
|row| row.get(0),
)
.optional()
}
pub fn mailbox_folders(
conn: &Connection,
source_id: i64,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT folder, local_id FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, MAILBOX], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (f, l) = r?;
map.insert(f, l);
}
Ok(map)
}
pub fn email_uids_in_folder(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<HashMap<(u32, u32), i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT uidvalidity, uid, local_id FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3",
)?;
let rows = stmt.query_map(params![source_id, EMAIL, folder], |row| {
Ok((
row.get::<_, u32>(0)?,
row.get::<_, u32>(1)?,
row.get::<_, i64>(2)?,
))
})?;
let mut map = HashMap::new();
for r in rows {
let (v, u, l) = r?;
map.insert((v, u), l);
}
Ok(map)
}
pub fn delete_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3
AND uidvalidity = 0 AND uid = 0",
params![source_id, MAILBOX, folder],
)?;
Ok(())
}
pub fn delete_email(
conn: &Connection,
source_id: i64,
folder: &str,
uidvalidity: u32,
uid: u32,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2
AND folder = ?3 AND uidvalidity = ?4 AND uid = ?5",
params![source_id, EMAIL, folder, uidvalidity, uid],
)?;
Ok(())
}
pub fn delete_all_emails_in_folder(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<usize, rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3",
params![source_id, EMAIL, folder],
)
}
pub fn email_uids_at_validity(
conn: &Connection,
source_id: i64,
folder: &str,
uidvalidity: u32,
) -> Result<Vec<u32>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT uid FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3
AND uidvalidity = ?4
ORDER BY uid",
)?;
let rows = stmt.query_map(params![source_id, EMAIL, folder, uidvalidity], |row| {
row.get::<_, u32>(0)
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
pub fn folders_with_emails(
conn: &Connection,
source_id: i64,
) -> Result<HashSet<String>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT DISTINCT folder FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, EMAIL], |row| row.get::<_, String>(0))?;
let mut out = HashSet::new();
for r in rows {
out.insert(r?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "imap".to_owned(),
session_url: "imaps://host:993".to_owned(),
account_id: "alice".to_owned(),
},
None,
"alice",
)
.unwrap();
(c, sid)
}
#[test]
fn mailbox_roundtrip() {
let (c, sid) = setup();
insert_mailbox(&c, sid, "INBOX", 7).unwrap();
insert_mailbox(&c, sid, "INBOX/Sub", 8).unwrap();
assert_eq!(local_for_mailbox(&c, sid, "INBOX").unwrap(), Some(7));
let all = mailbox_folders(&c, sid).unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all.get("INBOX/Sub"), Some(&8));
delete_mailbox(&c, sid, "INBOX").unwrap();
assert_eq!(local_for_mailbox(&c, sid, "INBOX").unwrap(), None);
}
#[test]
fn email_roundtrip() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", 12345, 100, 1).unwrap();
insert_email(&c, sid, "INBOX", 12345, 101, 2).unwrap();
insert_email(&c, sid, "Sent", 7777, 1, 3).unwrap();
assert_eq!(
local_for_email(&c, sid, "INBOX", 12345, 100).unwrap(),
Some(1)
);
let inbox = email_uids_in_folder(&c, sid, "INBOX").unwrap();
assert_eq!(inbox.len(), 2);
assert_eq!(inbox.get(&(12345, 100)), Some(&1));
let folders = folders_with_emails(&c, sid).unwrap();
assert!(folders.contains("INBOX"));
assert!(folders.contains("Sent"));
}
#[test]
fn delete_all_emails_in_folder_wipes_only_that_folder() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", 1, 10, 1).unwrap();
insert_email(&c, sid, "INBOX", 1, 11, 2).unwrap();
insert_email(&c, sid, "Sent", 1, 10, 3).unwrap();
let removed = delete_all_emails_in_folder(&c, sid, "INBOX").unwrap();
assert_eq!(removed, 2);
assert!(email_uids_in_folder(&c, sid, "INBOX").unwrap().is_empty());
assert_eq!(email_uids_in_folder(&c, sid, "Sent").unwrap().len(), 1);
}
#[test]
fn re_insert_with_same_local_id_replaces() {
let (c, sid) = setup();
insert_mailbox(&c, sid, "A", 1).unwrap();
insert_mailbox(&c, sid, "B", 1).unwrap();
assert_eq!(local_for_mailbox(&c, sid, "A").unwrap(), None);
assert_eq!(local_for_mailbox(&c, sid, "B").unwrap(), Some(1));
let all = mailbox_folders(&c, sid).unwrap();
assert_eq!(all.len(), 1);
}
#[test]
fn pk_composite_is_per_type_per_folder_per_uid() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", 1, 10, 1).unwrap();
insert_email(&c, sid, "INBOX", 1, 10, 1).unwrap();
assert_eq!(email_uids_in_folder(&c, sid, "INBOX").unwrap().len(), 1);
}
#[test]
fn mailbox_and_email_namespaces_do_not_collide_on_local_id() {
let (c, sid) = setup();
insert_mailbox(&c, sid, "INBOX", 42).unwrap();
insert_email(&c, sid, "INBOX", 1, 1, 42).unwrap();
}
}
+110
View File
@@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use rusqlite::{Connection, OptionalExtension, params};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FolderState {
pub uidvalidity: u32,
pub uidnext: u32,
pub last_seen: String,
}
pub fn get(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<Option<FolderState>, rusqlite::Error> {
conn.query_row(
"SELECT uidvalidity, uidnext, last_seen FROM imap_folder_state
WHERE source_id = ?1 AND folder = ?2",
params![source_id, folder],
|row| {
Ok(FolderState {
uidvalidity: row.get(0)?,
uidnext: row.get(1)?,
last_seen: row.get(2)?,
})
},
)
.optional()
}
pub fn upsert(
conn: &Connection,
source_id: i64,
folder: &str,
uidvalidity: u32,
uidnext: u32,
last_seen_rfc3339: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO imap_folder_state (source_id, folder, uidvalidity, uidnext, last_seen)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT (source_id, folder)
DO UPDATE SET uidvalidity = excluded.uidvalidity,
uidnext = excluded.uidnext,
last_seen = excluded.last_seen",
params![source_id, folder, uidvalidity, uidnext, last_seen_rfc3339],
)?;
Ok(())
}
pub fn delete(conn: &Connection, source_id: i64, folder: &str) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM imap_folder_state WHERE source_id = ?1 AND folder = ?2",
params![source_id, folder],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "imap".to_owned(),
session_url: "imaps://host:993".to_owned(),
account_id: "alice".to_owned(),
},
None,
"alice",
)
.unwrap();
(c, sid)
}
#[test]
fn upsert_then_get_returns_latest() {
let (c, sid) = setup();
assert!(get(&c, sid, "INBOX").unwrap().is_none());
upsert(&c, sid, "INBOX", 100, 50, "2026-05-24T00:00:00Z").unwrap();
let st = get(&c, sid, "INBOX").unwrap().unwrap();
assert_eq!(st.uidvalidity, 100);
assert_eq!(st.uidnext, 50);
upsert(&c, sid, "INBOX", 100, 75, "2026-05-24T01:00:00Z").unwrap();
let st = get(&c, sid, "INBOX").unwrap().unwrap();
assert_eq!(st.uidnext, 75);
assert_eq!(st.last_seen, "2026-05-24T01:00:00Z");
}
#[test]
fn delete_removes_only_named_folder() {
let (c, sid) = setup();
upsert(&c, sid, "INBOX", 1, 1, "2026-05-24T00:00:00Z").unwrap();
upsert(&c, sid, "Sent", 2, 1, "2026-05-24T00:00:00Z").unwrap();
delete(&c, sid, "INBOX").unwrap();
assert!(get(&c, sid, "INBOX").unwrap().is_none());
assert!(get(&c, sid, "Sent").unwrap().is_some());
}
}
+50
View File
@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use rusqlite::Connection;
pub const SCHEMA_SQL: &str = include_str!("schema.sql");
pub fn open(path: &std::path::Path) -> Result<Connection, OpenError> {
let conn = Connection::open(path)?;
apply_pragmas(&conn)?;
apply_schema(&conn)?;
Ok(conn)
}
pub fn apply_schema(conn: &Connection) -> Result<(), OpenError> {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(SCHEMA_SQL)?;
ensure_calendar_events_data_type(&tx)?;
tx.commit()?;
Ok(())
}
fn ensure_calendar_events_data_type(conn: &Connection) -> Result<(), OpenError> {
let mut stmt = conn.prepare("PRAGMA table_info(calendar_events)")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
let has_column = rows.filter_map(|r| r.ok()).any(|name| name == "data_type");
if !has_column {
conn.execute(
"ALTER TABLE calendar_events ADD COLUMN data_type TEXT NOT NULL DEFAULT 'Event'",
[],
)?;
}
Ok(())
}
fn apply_pragmas(conn: &Connection) -> Result<(), OpenError> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "locking_mode", "EXCLUSIVE")?;
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum OpenError {
#[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
}
+269
View File
@@ -0,0 +1,269 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::{HashMap, HashSet};
use rusqlite::{Connection, OptionalExtension, params};
const MAILBOX: &str = "mailbox";
const EMAIL: &str = "email";
pub fn insert_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_maildir
(source_id, type_name, folder, unique_id, local_id)
VALUES (?1, ?2, ?3, '', ?4)",
params![source_id, MAILBOX, folder, local_id],
)?;
Ok(())
}
pub fn insert_email(
conn: &Connection,
source_id: i64,
folder: &str,
unique_id: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_maildir
(source_id, type_name, folder, unique_id, local_id)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![source_id, EMAIL, folder, unique_id, local_id],
)?;
Ok(())
}
pub fn local_for_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3 AND unique_id = ''",
params![source_id, MAILBOX, folder],
|row| row.get(0),
)
.optional()
}
pub fn local_for_email(
conn: &Connection,
source_id: i64,
folder: &str,
unique_id: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3 AND unique_id = ?4",
params![source_id, EMAIL, folder, unique_id],
|row| row.get(0),
)
.optional()
}
pub fn mailbox_folders(
conn: &Connection,
source_id: i64,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT folder, local_id FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND unique_id = ''",
)?;
let rows = stmt.query_map(params![source_id, MAILBOX], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (f, l) = r?;
map.insert(f, l);
}
Ok(map)
}
pub fn email_ids_in_folder(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT unique_id, local_id FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3",
)?;
let rows = stmt.query_map(params![source_id, EMAIL, folder], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (u, l) = r?;
map.insert(u, l);
}
Ok(map)
}
pub fn delete_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3 AND unique_id = ''",
params![source_id, MAILBOX, folder],
)?;
Ok(())
}
pub fn delete_email(
conn: &Connection,
source_id: i64,
folder: &str,
unique_id: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3 AND unique_id = ?4",
params![source_id, EMAIL, folder, unique_id],
)?;
Ok(())
}
pub fn delete_all_emails_in_folder(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<usize, rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3",
params![source_id, EMAIL, folder],
)
}
pub fn folders_with_emails(
conn: &Connection,
source_id: i64,
) -> Result<HashSet<String>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT DISTINCT folder FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, EMAIL], |row| row.get::<_, String>(0))?;
let mut out = HashSet::new();
for r in rows {
out.insert(r?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "maildir".to_owned(),
session_url: "file:///home/alice/Maildir".to_owned(),
account_id: "/home/alice/Maildir".to_owned(),
},
Some("Maildir"),
"",
)
.unwrap();
(c, sid)
}
#[test]
fn mailbox_roundtrip() {
let (c, sid) = setup();
insert_mailbox(&c, sid, "INBOX", 7).unwrap();
insert_mailbox(&c, sid, "Archive.2025", 8).unwrap();
assert_eq!(local_for_mailbox(&c, sid, "INBOX").unwrap(), Some(7));
let all = mailbox_folders(&c, sid).unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all.get("Archive.2025"), Some(&8));
delete_mailbox(&c, sid, "INBOX").unwrap();
assert_eq!(local_for_mailbox(&c, sid, "INBOX").unwrap(), None);
}
#[test]
fn email_roundtrip() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", "1739471123.M001.host", 1).unwrap();
insert_email(&c, sid, "INBOX", "1739471200.M002.host", 2).unwrap();
insert_email(&c, sid, "Sent", "1739471300.M003.host", 3).unwrap();
assert_eq!(
local_for_email(&c, sid, "INBOX", "1739471123.M001.host").unwrap(),
Some(1)
);
let inbox = email_ids_in_folder(&c, sid, "INBOX").unwrap();
assert_eq!(inbox.len(), 2);
assert_eq!(inbox.get("1739471123.M001.host"), Some(&1));
let folders = folders_with_emails(&c, sid).unwrap();
assert!(folders.contains("INBOX"));
assert!(folders.contains("Sent"));
}
#[test]
fn delete_email_removes_only_that_row() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", "u1", 1).unwrap();
insert_email(&c, sid, "INBOX", "u2", 2).unwrap();
delete_email(&c, sid, "INBOX", "u1").unwrap();
let inbox = email_ids_in_folder(&c, sid, "INBOX").unwrap();
assert_eq!(inbox.len(), 1);
assert!(inbox.contains_key("u2"));
}
#[test]
fn delete_all_emails_in_folder_wipes_only_that_folder() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", "u1", 1).unwrap();
insert_email(&c, sid, "INBOX", "u2", 2).unwrap();
insert_email(&c, sid, "Sent", "u3", 3).unwrap();
let removed = delete_all_emails_in_folder(&c, sid, "INBOX").unwrap();
assert_eq!(removed, 2);
assert!(email_ids_in_folder(&c, sid, "INBOX").unwrap().is_empty());
assert_eq!(email_ids_in_folder(&c, sid, "Sent").unwrap().len(), 1);
}
#[test]
fn mailbox_and_email_namespaces_do_not_collide_on_local_id() {
let (c, sid) = setup();
insert_mailbox(&c, sid, "INBOX", 42).unwrap();
insert_email(&c, sid, "INBOX", "u1", 42).unwrap();
}
#[test]
fn re_insert_with_same_unique_id_is_rejected_by_pk() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", "u1", 1).unwrap();
let err = insert_email(&c, sid, "INBOX", "u1", 2).expect_err("PK conflict");
assert!(matches!(err, rusqlite::Error::SqliteFailure(_, _)));
assert_eq!(local_for_email(&c, sid, "INBOX", "u1").unwrap(), Some(1));
assert_eq!(email_ids_in_folder(&c, sid, "INBOX").unwrap().len(), 1);
}
#[test]
fn re_insert_with_same_local_id_is_rejected_by_unique_constraint() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", "u1", 42).unwrap();
let err = insert_email(&c, sid, "INBOX", "u2", 42).expect_err("UNIQUE conflict");
assert!(matches!(err, rusqlite::Error::SqliteFailure(_, _)));
}
}
+129
View File
@@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
pub fn insert(
conn: &Connection,
source_id: i64,
name: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT OR REPLACE INTO sync_id_managesieve (source_id, name, local_id)
VALUES (?1, ?2, ?3)",
params![source_id, name, local_id],
)?;
Ok(())
}
pub fn local_for(
conn: &Connection,
source_id: i64,
name: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_managesieve WHERE source_id = ?1 AND name = ?2",
params![source_id, name],
|row| row.get(0),
)
.optional()
}
pub fn all_names(
conn: &Connection,
source_id: i64,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt =
conn.prepare("SELECT name, local_id FROM sync_id_managesieve WHERE source_id = ?1")?;
let rows = stmt.query_map(params![source_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut out = HashMap::new();
for r in rows {
let (n, l) = r?;
out.insert(n, l);
}
Ok(out)
}
pub fn delete(conn: &Connection, source_id: i64, name: &str) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_managesieve WHERE source_id = ?1 AND name = ?2",
params![source_id, name],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "managesieve".to_owned(),
session_url: "sieve://host:4190".to_owned(),
account_id: "alice".to_owned(),
},
None,
"alice",
)
.unwrap();
(c, sid)
}
#[test]
fn insert_and_lookup_by_name() {
let (c, sid) = setup();
insert(&c, sid, "vacation", 7).unwrap();
assert_eq!(local_for(&c, sid, "vacation").unwrap(), Some(7));
assert_eq!(local_for(&c, sid, "missing").unwrap(), None);
}
#[test]
fn all_names_returns_full_map() {
let (c, sid) = setup();
insert(&c, sid, "a", 1).unwrap();
insert(&c, sid, "b", 2).unwrap();
let map = all_names(&c, sid).unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map.get("a"), Some(&1));
}
#[test]
fn delete_removes_row() {
let (c, sid) = setup();
insert(&c, sid, "a", 1).unwrap();
delete(&c, sid, "a").unwrap();
assert_eq!(local_for(&c, sid, "a").unwrap(), None);
}
#[test]
fn re_insert_with_same_name_replaces_local_id() {
let (c, sid) = setup();
insert(&c, sid, "a", 1).unwrap();
insert(&c, sid, "a", 2).unwrap();
assert_eq!(local_for(&c, sid, "a").unwrap(), Some(2));
assert_eq!(all_names(&c, sid).unwrap().len(), 1);
}
#[test]
fn insert_or_replace_reassigns_when_local_id_clashes() {
let (c, sid) = setup();
insert(&c, sid, "a", 1).unwrap();
insert(&c, sid, "b", 1).unwrap();
assert!(local_for(&c, sid, "a").unwrap().is_none());
assert_eq!(local_for(&c, sid, "b").unwrap(), Some(1));
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
pub mod blobs;
pub mod dav_ids;
pub mod exchange_ews_ids;
pub mod exchange_graph_ids;
pub mod ids;
pub mod imap_ids;
pub mod imap_state;
pub mod init;
pub mod maildir_ids;
pub mod managesieve_ids;
pub mod sources;
pub mod takeout_ids;
+249
View File
@@ -0,0 +1,249 @@
CREATE TABLE IF NOT EXISTS sources (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('jmap','imap','caldav','carddav','webdav','managesieve','maildir','exchange_ews','exchange_graph','takeout')),
session_url TEXT NOT NULL,
account_id TEXT NOT NULL,
account_name TEXT,
username TEXT NOT NULL,
UNIQUE (kind, session_url, account_id)
);
CREATE TABLE IF NOT EXISTS blobs (
id INTEGER PRIMARY KEY,
hash BLOB NOT NULL UNIQUE,
data BLOB NOT NULL
);
CREATE INDEX IF NOT EXISTS blobs_hash_idx ON blobs (hash);
CREATE TABLE IF NOT EXISTS sync_id_jmap (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL,
jmap_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, jmap_id),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_jmap_local_idx
ON sync_id_jmap (type_name, local_id);
CREATE TABLE IF NOT EXISTS sync_id_imap (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN ('mailbox','email')),
folder TEXT NOT NULL,
uidvalidity INTEGER NOT NULL,
uid INTEGER NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, folder, uidvalidity, uid),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_imap_folder_idx
ON sync_id_imap (source_id, type_name, folder);
CREATE TABLE IF NOT EXISTS imap_folder_state (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
folder TEXT NOT NULL,
uidvalidity INTEGER NOT NULL,
uidnext INTEGER NOT NULL,
last_seen TEXT NOT NULL,
PRIMARY KEY (source_id, folder)
);
CREATE TABLE IF NOT EXISTS sync_id_dav (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN (
'calendar','calendarevent',
'addressbook','contactcard',
'filenode')),
collection_href TEXT NOT NULL,
item_href TEXT NOT NULL,
etag TEXT NOT NULL DEFAULT '',
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, item_href),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_dav_collection_idx
ON sync_id_dav (source_id, type_name, collection_href);
CREATE TABLE IF NOT EXISTS sync_id_managesieve (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
name TEXT NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, name),
UNIQUE (source_id, local_id)
);
CREATE TABLE IF NOT EXISTS sync_id_maildir (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN ('mailbox','email')),
folder TEXT NOT NULL,
unique_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, folder, unique_id),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_maildir_folder_idx
ON sync_id_maildir (source_id, type_name, folder);
CREATE TABLE IF NOT EXISTS sync_id_exchange_ews (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN (
'mailbox','calendar','addressbook',
'email','calendarevent','contactcard')),
folder_id TEXT NOT NULL DEFAULT '',
item_id TEXT NOT NULL,
change_key TEXT NOT NULL DEFAULT '',
sync_state TEXT NOT NULL DEFAULT '',
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, item_id),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_exchange_ews_folder_idx
ON sync_id_exchange_ews (source_id, type_name, folder_id);
CREATE TABLE IF NOT EXISTS sync_id_exchange_graph (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN (
'mailbox','email',
'calendar','calendarevent',
'addressbook','contactcard')),
graph_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, graph_id),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_exchange_graph_type_idx
ON sync_id_exchange_graph (source_id, type_name);
CREATE TABLE IF NOT EXISTS mailboxes (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES mailboxes(id) ON DELETE RESTRICT,
role TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_subscribed INTEGER NOT NULL DEFAULT 1,
UNIQUE (parent_id, name)
);
CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY,
blob_id INTEGER NOT NULL REFERENCES blobs(id),
received_at TEXT NOT NULL,
mailbox_ids TEXT NOT NULL CHECK (json_valid(mailbox_ids)),
keywords TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(keywords)),
message_match TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(message_match))
);
CREATE INDEX IF NOT EXISTS emails_blob_idx ON emails (blob_id);
CREATE TABLE IF NOT EXISTS identities (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL,
reply_to TEXT CHECK (reply_to IS NULL OR json_valid(reply_to)),
bcc TEXT CHECK (bcc IS NULL OR json_valid(bcc)),
text_signature TEXT NOT NULL DEFAULT '',
html_signature TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS sieve_scripts (
id INTEGER PRIMARY KEY,
name TEXT,
is_active INTEGER NOT NULL DEFAULT 0,
blob_id INTEGER NOT NULL REFERENCES blobs(id),
UNIQUE (name)
);
CREATE UNIQUE INDEX IF NOT EXISTS sieve_scripts_one_active_idx
ON sieve_scripts (is_active) WHERE is_active = 1;
CREATE TABLE IF NOT EXISTS address_books (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0,
is_subscribed INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS contact_cards (
id INTEGER PRIMARY KEY,
uid TEXT NOT NULL UNIQUE,
address_book_ids TEXT NOT NULL CHECK (json_valid(address_book_ids)),
data TEXT NOT NULL CHECK (json_valid(data))
);
CREATE TABLE IF NOT EXISTS calendars (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
color TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_subscribed INTEGER NOT NULL DEFAULT 1,
is_visible INTEGER NOT NULL DEFAULT 1,
is_default INTEGER NOT NULL DEFAULT 0,
include_in_availability TEXT NOT NULL DEFAULT 'all'
CHECK (include_in_availability IN ('all','attending','none')),
default_alerts_with_time TEXT CHECK (default_alerts_with_time IS NULL
OR json_valid(default_alerts_with_time)),
default_alerts_without_time TEXT CHECK (default_alerts_without_time IS NULL
OR json_valid(default_alerts_without_time)),
time_zone TEXT
);
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY,
calendar_ids TEXT NOT NULL CHECK (json_valid(calendar_ids)),
is_draft INTEGER NOT NULL DEFAULT 0,
use_default_alerts INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL CHECK (json_valid(data)),
data_type TEXT NOT NULL DEFAULT 'Event'
CHECK (data_type IN ('Event','Task','Note','Group'))
);
CREATE TABLE IF NOT EXISTS participant_identities (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
calendar_address TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS file_nodes (
id INTEGER PRIMARY KEY,
parent_id INTEGER REFERENCES file_nodes(id) ON DELETE RESTRICT,
node_type TEXT NOT NULL
CHECK (node_type IN ('file','directory','symlink')),
blob_id INTEGER REFERENCES blobs(id),
target TEXT CHECK (target IS NULL OR json_valid(target)),
name TEXT NOT NULL,
media_type TEXT,
created TEXT NOT NULL,
modified TEXT,
is_subscribed INTEGER NOT NULL DEFAULT 1,
role TEXT,
UNIQUE (parent_id, name)
);
CREATE INDEX IF NOT EXISTS file_nodes_parent_idx ON file_nodes (parent_id);
CREATE INDEX IF NOT EXISTS file_nodes_blob_idx ON file_nodes (blob_id);
CREATE TABLE IF NOT EXISTS sync_id_takeout (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN (
'mailbox','email',
'addressbook','contactcard',
'calendar','calendarevent')),
source_obj_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, source_obj_id),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_takeout_type_idx
ON sync_id_takeout (source_id, type_name);
+105
View File
@@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use rusqlite::{Connection, OptionalExtension, params};
#[derive(Debug, Clone)]
pub struct SourceKey {
pub kind: String,
pub session_url: String,
pub account_id: String,
}
pub fn find_source(conn: &Connection, key: &SourceKey) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT id FROM sources WHERE kind = ?1 AND session_url = ?2 AND account_id = ?3",
params![key.kind, key.session_url, key.account_id],
|row| row.get(0),
)
.optional()
}
pub fn conflicting_source(
conn: &Connection,
kind: &str,
session_url: &str,
account_id: &str,
) -> Result<Option<(String, String)>, rusqlite::Error> {
conn.query_row(
"SELECT session_url, account_id FROM sources
WHERE kind = ?1 AND NOT (session_url = ?2 AND account_id = ?3) LIMIT 1",
params![kind, session_url, account_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
}
pub fn upsert_source(
conn: &Connection,
key: &SourceKey,
account_name: Option<&str>,
username: &str,
) -> Result<i64, rusqlite::Error> {
conn.execute(
"INSERT INTO sources (kind, session_url, account_id, account_name, username)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT (kind, session_url, account_id)
DO UPDATE SET account_name = excluded.account_name, username = excluded.username",
params![
key.kind,
key.session_url,
key.account_id,
account_name,
username
],
)?;
find_source(conn, key).map(|opt| opt.unwrap_or_else(|| conn.last_insert_rowid()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
c
}
fn key(url: &str, acc: &str) -> SourceKey {
SourceKey {
kind: "jmap".to_owned(),
session_url: url.to_owned(),
account_id: acc.to_owned(),
}
}
#[test]
fn upsert_is_idempotent_and_returns_stable_id() {
let c = mem();
let k = key("https://a/jmap", "w");
let id1 = upsert_source(&c, &k, Some("alice"), "alice").unwrap();
let id2 = upsert_source(&c, &k, Some("alice2"), "alice").unwrap();
assert_eq!(id1, id2);
assert!(find_source(&c, &k).unwrap().is_some());
}
#[test]
fn conflicting_source_detects_a_different_account() {
let c = mem();
upsert_source(&c, &key("https://a/jmap", "w"), None, "alice").unwrap();
assert!(
conflicting_source(&c, "jmap", "https://a/jmap", "w")
.unwrap()
.is_none()
);
let conflict = conflicting_source(&c, "jmap", "https://b/jmap", "v")
.unwrap()
.unwrap();
assert_eq!(conflict, ("https://a/jmap".to_owned(), "w".to_owned()));
}
}
+177
View File
@@ -0,0 +1,177 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
pub const MAILBOX: &str = "mailbox";
pub const EMAIL: &str = "email";
pub const ADDRESS_BOOK: &str = "addressbook";
pub const CONTACT_CARD: &str = "contactcard";
pub const CALENDAR: &str = "calendar";
pub const CALENDAR_EVENT: &str = "calendarevent";
pub fn insert(
conn: &Connection,
source_id: i64,
type_name: &str,
source_obj_id: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_takeout
(source_id, type_name, source_obj_id, local_id)
VALUES (?1, ?2, ?3, ?4)",
params![source_id, type_name, source_obj_id, local_id],
)?;
Ok(())
}
pub fn local_for(
conn: &Connection,
source_id: i64,
type_name: &str,
source_obj_id: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_takeout
WHERE source_id = ?1 AND type_name = ?2 AND source_obj_id = ?3",
params![source_id, type_name, source_obj_id],
|row| row.get(0),
)
.optional()
}
pub fn delete(
conn: &Connection,
source_id: i64,
type_name: &str,
source_obj_id: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_takeout
WHERE source_id = ?1 AND type_name = ?2 AND source_obj_id = ?3",
params![source_id, type_name, source_obj_id],
)?;
Ok(())
}
pub fn all_for_type(
conn: &Connection,
source_id: i64,
type_name: &str,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT source_obj_id, local_id FROM sync_id_takeout
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, type_name], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (k, v) = r?;
map.insert(k, v);
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "takeout".to_owned(),
session_url: "file:///tmp/Takeout".to_owned(),
account_id: "/tmp/Takeout".to_owned(),
},
Some("Takeout"),
"",
)
.unwrap();
(c, sid)
}
#[test]
fn insert_and_lookup_roundtrip() {
let (c, sid) = setup();
insert(&c, sid, MAILBOX, "Inbox", 7).unwrap();
insert(&c, sid, MAILBOX, "Label/Sub", 8).unwrap();
assert_eq!(local_for(&c, sid, MAILBOX, "Inbox").unwrap(), Some(7));
assert_eq!(local_for(&c, sid, MAILBOX, "Label/Sub").unwrap(), Some(8));
assert_eq!(local_for(&c, sid, MAILBOX, "Missing").unwrap(), None);
}
#[test]
fn types_are_isolated_namespaces() {
let (c, sid) = setup();
insert(&c, sid, MAILBOX, "Inbox", 1).unwrap();
insert(&c, sid, EMAIL, "ab12", 2).unwrap();
insert(&c, sid, ADDRESS_BOOK, "Imported", 3).unwrap();
insert(&c, sid, CONTACT_CARD, "uid-1", 4).unwrap();
insert(&c, sid, CALENDAR, "Imported", 5).unwrap();
insert(&c, sid, CALENDAR_EVENT, "uid-e1", 6).unwrap();
assert_eq!(all_for_type(&c, sid, MAILBOX).unwrap().len(), 1);
assert_eq!(all_for_type(&c, sid, EMAIL).unwrap().len(), 1);
assert_eq!(all_for_type(&c, sid, ADDRESS_BOOK).unwrap().len(), 1);
assert_eq!(all_for_type(&c, sid, CONTACT_CARD).unwrap().len(), 1);
assert_eq!(all_for_type(&c, sid, CALENDAR).unwrap().len(), 1);
assert_eq!(all_for_type(&c, sid, CALENDAR_EVENT).unwrap().len(), 1);
}
#[test]
fn delete_removes_only_target_row() {
let (c, sid) = setup();
insert(&c, sid, EMAIL, "hash-a", 1).unwrap();
insert(&c, sid, EMAIL, "hash-b", 2).unwrap();
delete(&c, sid, EMAIL, "hash-a").unwrap();
assert_eq!(local_for(&c, sid, EMAIL, "hash-a").unwrap(), None);
assert_eq!(local_for(&c, sid, EMAIL, "hash-b").unwrap(), Some(2));
}
#[test]
fn pk_rejects_duplicate_source_obj_id() {
let (c, sid) = setup();
insert(&c, sid, EMAIL, "h", 1).unwrap();
let err = insert(&c, sid, EMAIL, "h", 2).expect_err("PK conflict");
assert!(matches!(err, rusqlite::Error::SqliteFailure(_, _)));
}
#[test]
fn unique_constraint_rejects_duplicate_local_id_within_type() {
let (c, sid) = setup();
insert(&c, sid, EMAIL, "h1", 42).unwrap();
let err = insert(&c, sid, EMAIL, "h2", 42).expect_err("UNIQUE conflict");
assert!(matches!(err, rusqlite::Error::SqliteFailure(_, _)));
}
#[test]
fn same_local_id_allowed_across_different_types() {
let (c, sid) = setup();
insert(&c, sid, MAILBOX, "Inbox", 42).unwrap();
insert(&c, sid, EMAIL, "h1", 42).unwrap();
}
#[test]
fn all_for_type_returns_full_map() {
let (c, sid) = setup();
insert(&c, sid, EMAIL, "h1", 1).unwrap();
insert(&c, sid, EMAIL, "h2", 2).unwrap();
insert(&c, sid, EMAIL, "h3", 3).unwrap();
let all = all_for_type(&c, sid, EMAIL).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all.get("h1"), Some(&1));
assert_eq!(all.get("h3"), Some(&3));
}
}
+77
View File
@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use crate::db::init::OpenError;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("usage error: {0}")]
Usage(String),
#[error("connection error: {0}")]
Connection(String),
#[error("account resolution failed: {0}")]
Account(String),
#[error("source-change protection: {0}")]
SourceChange(String),
#[error("partial failure: {0}")]
Partial(String),
#[error("aborted at prune confirmation")]
PruneAborted,
#[error("not yet implemented: {0}")]
Unimplemented(&'static str),
#[error("archive error: {0}")]
Db(#[from] OpenError),
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
}
impl From<rusqlite::Error> for Error {
fn from(e: rusqlite::Error) -> Self {
Error::Db(OpenError::Sqlite(e))
}
}
impl Error {
pub fn exit_code(&self) -> i32 {
match self {
Error::Usage(_) => 1,
Error::Connection(_) => 2,
Error::Account(_) => 3,
Error::SourceChange(_) => 4,
Error::Partial(_) => 5,
Error::PruneAborted => 6,
Error::Unimplemented(_) => 1,
Error::Db(_) => 7,
Error::Io(_) => 7,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exit_codes_match_cli_md_table() {
assert_eq!(Error::Usage("x".into()).exit_code(), 1);
assert_eq!(Error::Unimplemented("x").exit_code(), 1);
assert_eq!(Error::Connection("x".into()).exit_code(), 2);
assert_eq!(Error::Account("x".into()).exit_code(), 3);
assert_eq!(Error::SourceChange("x".into()).exit_code(), 4);
assert_eq!(Error::Partial("x".into()).exit_code(), 5);
assert_eq!(Error::PruneAborted.exit_code(), 6);
let io = Error::Io(std::io::Error::other("disk"));
assert_eq!(io.exit_code(), 7);
}
}
+7
View File
@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
pub mod tz;
+258
View File
@@ -0,0 +1,258 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
pub fn windows_to_iana(windows_name: &str) -> Option<&'static str> {
let key = windows_name.trim();
for (w, i) in TABLE {
if w.eq_ignore_ascii_case(key) {
return Some(i);
}
}
None
}
const TABLE: &[(&str, &str)] = &[
("UTC", "Etc/UTC"),
("Coordinated Universal Time", "Etc/UTC"),
("UTC-11", "Etc/GMT+11"),
("UTC-09", "Etc/GMT+9"),
("UTC-08", "Etc/GMT+8"),
("UTC-02", "Etc/GMT+2"),
("UTC+12", "Etc/GMT-12"),
("UTC+13", "Etc/GMT-13"),
("Dateline Standard Time", "Etc/GMT+12"),
("Aleutian Standard Time", "America/Adak"),
("Hawaiian Standard Time", "Pacific/Honolulu"),
("Marquesas Standard Time", "Pacific/Marquesas"),
("Alaskan Standard Time", "America/Anchorage"),
("Pacific Standard Time (Mexico)", "America/Tijuana"),
("Pacific Standard Time", "America/Los_Angeles"),
("US Mountain Standard Time", "America/Phoenix"),
("Mountain Standard Time (Mexico)", "America/Mazatlan"),
("Mountain Standard Time", "America/Denver"),
("Yukon Standard Time", "America/Whitehorse"),
("Central America Standard Time", "America/Guatemala"),
("Central Standard Time", "America/Chicago"),
("Easter Island Standard Time", "Pacific/Easter"),
("Central Standard Time (Mexico)", "America/Mexico_City"),
("Canada Central Standard Time", "America/Regina"),
("SA Pacific Standard Time", "America/Bogota"),
("Eastern Standard Time (Mexico)", "America/Cancun"),
("Eastern Standard Time", "America/New_York"),
("Haiti Standard Time", "America/Port-au-Prince"),
("Cuba Standard Time", "America/Havana"),
("US Eastern Standard Time", "America/Indianapolis"),
("Turks And Caicos Standard Time", "America/Grand_Turk"),
("Paraguay Standard Time", "America/Asuncion"),
("Atlantic Standard Time", "America/Halifax"),
("Venezuela Standard Time", "America/Caracas"),
("Central Brazilian Standard Time", "America/Cuiaba"),
("SA Western Standard Time", "America/La_Paz"),
("Pacific SA Standard Time", "America/Santiago"),
("Newfoundland Standard Time", "America/St_Johns"),
("Tocantins Standard Time", "America/Araguaina"),
("E. South America Standard Time", "America/Sao_Paulo"),
("SA Eastern Standard Time", "America/Cayenne"),
("Argentina Standard Time", "America/Argentina/Buenos_Aires"),
("Greenland Standard Time", "America/Godthab"),
("Montevideo Standard Time", "America/Montevideo"),
("Magallanes Standard Time", "America/Punta_Arenas"),
("Saint Pierre Standard Time", "America/Miquelon"),
("Bahia Standard Time", "America/Bahia"),
("Mid-Atlantic Standard Time", "Atlantic/South_Georgia"),
("Azores Standard Time", "Atlantic/Azores"),
("Cape Verde Standard Time", "Atlantic/Cape_Verde"),
("GMT Standard Time", "Europe/London"),
("Greenwich Standard Time", "Atlantic/Reykjavik"),
("Sao Tome Standard Time", "Africa/Sao_Tome"),
("Morocco Standard Time", "Africa/Casablanca"),
("W. Europe Standard Time", "Europe/Berlin"),
("Central Europe Standard Time", "Europe/Budapest"),
("Romance Standard Time", "Europe/Paris"),
("Central European Standard Time", "Europe/Warsaw"),
("W. Central Africa Standard Time", "Africa/Lagos"),
("Jordan Standard Time", "Asia/Amman"),
("GTB Standard Time", "Europe/Bucharest"),
("Middle East Standard Time", "Asia/Beirut"),
("Egypt Standard Time", "Africa/Cairo"),
("E. Europe Standard Time", "Europe/Chisinau"),
("Syria Standard Time", "Asia/Damascus"),
("West Bank Standard Time", "Asia/Hebron"),
("South Africa Standard Time", "Africa/Johannesburg"),
("FLE Standard Time", "Europe/Kiev"),
("Israel Standard Time", "Asia/Jerusalem"),
("South Sudan Standard Time", "Africa/Juba"),
("Kaliningrad Standard Time", "Europe/Kaliningrad"),
("Sudan Standard Time", "Africa/Khartoum"),
("Libya Standard Time", "Africa/Tripoli"),
("Namibia Standard Time", "Africa/Windhoek"),
("Arabic Standard Time", "Asia/Baghdad"),
("Turkey Standard Time", "Europe/Istanbul"),
("Arab Standard Time", "Asia/Riyadh"),
("Belarus Standard Time", "Europe/Minsk"),
("Russian Standard Time", "Europe/Moscow"),
("E. Africa Standard Time", "Africa/Nairobi"),
("Iran Standard Time", "Asia/Tehran"),
("Arabian Standard Time", "Asia/Dubai"),
("Astrakhan Standard Time", "Europe/Astrakhan"),
("Azerbaijan Standard Time", "Asia/Baku"),
("Russia Time Zone 3", "Europe/Samara"),
("Mauritius Standard Time", "Indian/Mauritius"),
("Saratov Standard Time", "Europe/Saratov"),
("Georgian Standard Time", "Asia/Tbilisi"),
("Volgograd Standard Time", "Europe/Volgograd"),
("Caucasus Standard Time", "Asia/Yerevan"),
("Afghanistan Standard Time", "Asia/Kabul"),
("West Asia Standard Time", "Asia/Tashkent"),
("Ekaterinburg Standard Time", "Asia/Yekaterinburg"),
("Pakistan Standard Time", "Asia/Karachi"),
("Qyzylorda Standard Time", "Asia/Qyzylorda"),
("India Standard Time", "Asia/Kolkata"),
("Sri Lanka Standard Time", "Asia/Colombo"),
("Nepal Standard Time", "Asia/Katmandu"),
("Central Asia Standard Time", "Asia/Almaty"),
("Bangladesh Standard Time", "Asia/Dhaka"),
("Omsk Standard Time", "Asia/Omsk"),
("Myanmar Standard Time", "Asia/Rangoon"),
("SE Asia Standard Time", "Asia/Bangkok"),
("Altai Standard Time", "Asia/Barnaul"),
("W. Mongolia Standard Time", "Asia/Hovd"),
("North Asia Standard Time", "Asia/Krasnoyarsk"),
("N. Central Asia Standard Time", "Asia/Novosibirsk"),
("Tomsk Standard Time", "Asia/Tomsk"),
("China Standard Time", "Asia/Shanghai"),
("North Asia East Standard Time", "Asia/Irkutsk"),
("Singapore Standard Time", "Asia/Singapore"),
("W. Australia Standard Time", "Australia/Perth"),
("Taipei Standard Time", "Asia/Taipei"),
("Ulaanbaatar Standard Time", "Asia/Ulaanbaatar"),
("Aus Central W. Standard Time", "Australia/Eucla"),
("Transbaikal Standard Time", "Asia/Chita"),
("Tokyo Standard Time", "Asia/Tokyo"),
("North Korea Standard Time", "Asia/Pyongyang"),
("Korea Standard Time", "Asia/Seoul"),
("Yakutsk Standard Time", "Asia/Yakutsk"),
("Cen. Australia Standard Time", "Australia/Adelaide"),
("AUS Central Standard Time", "Australia/Darwin"),
("E. Australia Standard Time", "Australia/Brisbane"),
("AUS Eastern Standard Time", "Australia/Sydney"),
("West Pacific Standard Time", "Pacific/Port_Moresby"),
("Tasmania Standard Time", "Australia/Hobart"),
("Vladivostok Standard Time", "Asia/Vladivostok"),
("Lord Howe Standard Time", "Australia/Lord_Howe"),
("Bougainville Standard Time", "Pacific/Bougainville"),
("Russia Time Zone 10", "Asia/Srednekolymsk"),
("Magadan Standard Time", "Asia/Magadan"),
("Norfolk Standard Time", "Pacific/Norfolk"),
("Sakhalin Standard Time", "Asia/Sakhalin"),
("Central Pacific Standard Time", "Pacific/Guadalcanal"),
("Russia Time Zone 11", "Asia/Kamchatka"),
("New Zealand Standard Time", "Pacific/Auckland"),
("Fiji Standard Time", "Pacific/Fiji"),
("Kamchatka Standard Time", "Asia/Kamchatka"),
("Chatham Islands Standard Time", "Pacific/Chatham"),
("Tonga Standard Time", "Pacific/Tongatapu"),
("Samoa Standard Time", "Pacific/Apia"),
("Line Islands Standard Time", "Pacific/Kiritimati"),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pacific_standard_time_maps_to_los_angeles() {
assert_eq!(
windows_to_iana("Pacific Standard Time"),
Some("America/Los_Angeles")
);
}
#[test]
fn romance_standard_time_maps_to_paris() {
assert_eq!(
windows_to_iana("Romance Standard Time"),
Some("Europe/Paris")
);
}
#[test]
fn unknown_zone_returns_none() {
assert_eq!(windows_to_iana("Made Up Zone"), None);
}
#[test]
fn case_insensitive_lookup() {
assert_eq!(
windows_to_iana("eastern standard time"),
Some("America/New_York")
);
}
#[test]
fn whitespace_is_trimmed() {
assert_eq!(
windows_to_iana(" GMT Standard Time "),
Some("Europe/London")
);
}
#[test]
fn full_cldr_table_has_at_least_140_rows() {
assert!(TABLE.len() >= 140, "got {} rows", TABLE.len());
}
#[test]
fn spot_check_coverage_across_continents() {
assert_eq!(windows_to_iana("Tokyo Standard Time"), Some("Asia/Tokyo"));
assert_eq!(
windows_to_iana("AUS Eastern Standard Time"),
Some("Australia/Sydney")
);
assert_eq!(
windows_to_iana("Cen. Australia Standard Time"),
Some("Australia/Adelaide")
);
assert_eq!(
windows_to_iana("Greenland Standard Time"),
Some("America/Godthab")
);
assert_eq!(
windows_to_iana("China Standard Time"),
Some("Asia/Shanghai")
);
assert_eq!(
windows_to_iana("Israel Standard Time"),
Some("Asia/Jerusalem")
);
assert_eq!(
windows_to_iana("Chatham Islands Standard Time"),
Some("Pacific/Chatham")
);
assert_eq!(
windows_to_iana("Newfoundland Standard Time"),
Some("America/St_Johns")
);
assert_eq!(windows_to_iana("UTC-08"), Some("Etc/GMT+8"));
assert_eq!(windows_to_iana("UTC+13"), Some("Etc/GMT-13"));
assert_eq!(
windows_to_iana("Yukon Standard Time"),
Some("America/Whitehorse")
);
assert_eq!(
windows_to_iana("Aus Central W. Standard Time"),
Some("Australia/Eucla")
);
}
#[test]
fn coordinated_universal_time_alias_resolves_to_utc() {
assert_eq!(
windows_to_iana("Coordinated Universal Time"),
Some("Etc/UTC")
);
}
}
+561
View File
@@ -0,0 +1,561 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use quick_xml::NsReader;
use quick_xml::events::Event;
use serde_json::Value;
use ureq::Agent;
use ureq::config::Config;
use ureq::tls::{RootCerts, TlsConfig};
use crate::exchange_ews::error::EwsError;
const V2_HOST: &str = "https://outlook.office365.com";
const POX_REQ_NS: &str =
"http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006";
const POX_RESP_NS: &str =
"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a";
pub fn is_fully_qualified_ews_url(url: &str) -> bool {
let parsed = match url::Url::parse(url) {
Ok(u) => u,
Err(_) => return false,
};
if parsed.scheme() != "https" {
return false;
}
parsed.path().eq_ignore_ascii_case("/EWS/Exchange.asmx")
}
#[derive(Debug, Clone)]
pub struct DiscoveryResult {
pub ews_url: String,
pub source: DiscoverySource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiscoverySource {
V2,
V1,
SuppliedUrl,
}
pub fn discover(
supplied_url: Option<&str>,
email: Option<&str>,
auth_header: Option<&str>,
allow_invalid_certs: bool,
) -> Result<DiscoveryResult, EwsError> {
if let Some(url) = supplied_url
&& is_fully_qualified_ews_url(url)
{
return Ok(DiscoveryResult {
ews_url: url.to_owned(),
source: DiscoverySource::SuppliedUrl,
});
}
let Some(email) = email else {
return Err(EwsError::AutodiscoverFailed(
"either a fully-qualified --url or --mailbox is required".to_owned(),
));
};
let agent = build_agent(allow_invalid_certs);
if let Ok(url) = autodiscover_v2(&agent, email) {
return Ok(DiscoveryResult {
ews_url: url,
source: DiscoverySource::V2,
});
}
let mut tried: Vec<String> = Vec::new();
let mut redirects: u8 = 0;
let mut url_redirects: u8 = 0;
let mut current_email = email.to_owned();
'outer: loop {
let domain = current_email
.split('@')
.nth(1)
.ok_or_else(|| EwsError::AutodiscoverFailed("--mailbox is not an email".to_owned()))?;
let candidates = pox_candidates(domain);
for candidate in &candidates {
tried.push(candidate.clone());
match autodiscover_v1(&agent, candidate, &current_email, auth_header) {
Ok(PoxOutcome::EwsUrl(url)) => {
return Ok(DiscoveryResult {
ews_url: url,
source: DiscoverySource::V1,
});
}
Ok(PoxOutcome::RedirectAddr(addr)) => {
if redirects >= 4 {
return Err(EwsError::AutodiscoverLoop);
}
redirects += 1;
current_email = addr;
continue 'outer;
}
Ok(PoxOutcome::RedirectUrl(url)) => {
if url_redirects >= 1 {
return Err(EwsError::AutodiscoverLoop);
}
url_redirects += 1;
tried.push(url.clone());
if let Ok(PoxOutcome::EwsUrl(u)) =
autodiscover_v1(&agent, &url, &current_email, auth_header)
{
return Ok(DiscoveryResult {
ews_url: u,
source: DiscoverySource::V1,
});
}
continue;
}
Err(_) | Ok(PoxOutcome::Failed) => continue,
}
}
break;
}
if let Some(url) = supplied_url {
return Ok(DiscoveryResult {
ews_url: url.to_owned(),
source: DiscoverySource::SuppliedUrl,
});
}
Err(EwsError::AutodiscoverFailed(format!(
"tried {} candidate(s), none returned a v1 EwsUrl",
tried.len()
)))
}
fn build_agent(allow_invalid_certs: bool) -> Agent {
let config: Config = Config::builder()
.http_status_as_error(false)
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.disable_verification(allow_invalid_certs)
.build(),
)
.build();
config.new_agent()
}
fn autodiscover_v2(agent: &Agent, email: &str) -> Result<String, EwsError> {
let url = format!(
"{V2_HOST}/autodiscover/autodiscover.json?Email={}&Protocol=Ews",
urlencode(email)
);
let mut resp = agent
.get(&url)
.header("Accept", "application/json")
.call()
.map_err(|e| EwsError::Transport(format!("autodiscover v2: {e}")))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(EwsError::AutodiscoverFailed(format!(
"v2 returned http {status}"
)));
}
let body: Value = resp
.body_mut()
.read_json()
.map_err(|e| EwsError::Malformed(format!("v2 body: {e}")))?;
let protocol = body.get("Protocol").and_then(Value::as_str).unwrap_or("");
if !protocol.eq_ignore_ascii_case("Ews") {
return Err(EwsError::AutodiscoverFailed(format!(
"v2 returned protocol {protocol:?}"
)));
}
let url = body
.get("Url")
.and_then(Value::as_str)
.ok_or_else(|| EwsError::AutodiscoverFailed("v2 missing Url".to_owned()))?;
Ok(url.to_owned())
}
#[derive(Debug, Clone)]
enum PoxOutcome {
EwsUrl(String),
RedirectAddr(String),
RedirectUrl(String),
Failed,
}
fn pox_candidates(domain: &str) -> Vec<String> {
let mut out = vec![
format!("https://autodiscover.{domain}/autodiscover/autodiscover.xml"),
format!("https://{domain}/autodiscover/autodiscover.xml"),
];
for SrvRecord { target, port, .. } in srv_lookup(&format!("_autodiscover._tcp.{domain}")) {
let host = target.trim_end_matches('.');
let suffix = if port == 443 {
String::new()
} else {
format!(":{port}")
};
out.push(format!(
"https://{host}{suffix}/autodiscover/autodiscover.xml"
));
}
out
}
#[derive(Debug, Clone)]
struct SrvRecord {
priority: u16,
weight: u16,
port: u16,
target: String,
}
fn srv_lookup(qname: &str) -> Vec<SrvRecord> {
use std::net::UdpSocket;
use std::time::Duration;
let nameservers = resolv_conf_nameservers();
if nameservers.is_empty() {
return Vec::new();
}
let mut query = Vec::with_capacity(64);
let txn_id: u16 = 0x1234;
query.extend_from_slice(&txn_id.to_be_bytes());
query.extend_from_slice(&0x0100u16.to_be_bytes());
query.extend_from_slice(&1u16.to_be_bytes());
query.extend_from_slice(&[0u8; 6]);
encode_qname(&mut query, qname);
query.extend_from_slice(&33u16.to_be_bytes());
query.extend_from_slice(&1u16.to_be_bytes());
let mut out: Vec<SrvRecord> = Vec::new();
for ns in nameservers {
let Ok(socket) = UdpSocket::bind("0.0.0.0:0") else {
continue;
};
let _ = socket.set_read_timeout(Some(Duration::from_secs(3)));
if socket.send_to(&query, format!("{ns}:53")).is_err() {
continue;
}
let mut buf = [0u8; 1500];
let Ok((n, _)) = socket.recv_from(&mut buf) else {
continue;
};
if let Some(records) = parse_srv_response(&buf[..n]) {
out = records;
break;
}
}
out.sort_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)));
out
}
fn resolv_conf_nameservers() -> Vec<String> {
let path = std::path::Path::new("/etc/resolv.conf");
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
let mut out: Vec<String> = Vec::new();
for line in text.lines() {
let line = line.split('#').next().unwrap_or("").trim();
if let Some(rest) = line.strip_prefix("nameserver") {
let ns = rest.trim();
if !ns.is_empty() {
out.push(ns.to_owned());
}
}
}
out
}
fn encode_qname(out: &mut Vec<u8>, qname: &str) {
for label in qname.trim_end_matches('.').split('.') {
if label.is_empty() {
continue;
}
out.push(label.len() as u8);
out.extend_from_slice(label.as_bytes());
}
out.push(0);
}
fn parse_srv_response(buf: &[u8]) -> Option<Vec<SrvRecord>> {
if buf.len() < 12 {
return None;
}
let qd = u16::from_be_bytes([buf[4], buf[5]]) as usize;
let an = u16::from_be_bytes([buf[6], buf[7]]) as usize;
let mut cursor = 12;
for _ in 0..qd {
cursor = skip_name(buf, cursor)?;
cursor = cursor.checked_add(4)?;
if cursor > buf.len() {
return None;
}
}
let mut out: Vec<SrvRecord> = Vec::new();
for _ in 0..an {
cursor = skip_name(buf, cursor)?;
if cursor + 10 > buf.len() {
return None;
}
let rtype = u16::from_be_bytes([buf[cursor], buf[cursor + 1]]);
let rdlength = u16::from_be_bytes([buf[cursor + 8], buf[cursor + 9]]) as usize;
cursor += 10;
let rdata_end = cursor.checked_add(rdlength)?;
if rdata_end > buf.len() {
return None;
}
if rtype == 33 && rdlength >= 7 {
let priority = u16::from_be_bytes([buf[cursor], buf[cursor + 1]]);
let weight = u16::from_be_bytes([buf[cursor + 2], buf[cursor + 3]]);
let port = u16::from_be_bytes([buf[cursor + 4], buf[cursor + 5]]);
let target = read_name(buf, cursor + 6)?;
out.push(SrvRecord {
priority,
weight,
port,
target,
});
}
cursor = rdata_end;
}
Some(out)
}
fn skip_name(buf: &[u8], mut cursor: usize) -> Option<usize> {
loop {
if cursor >= buf.len() {
return None;
}
let len = buf[cursor];
if len == 0 {
return Some(cursor + 1);
}
if (len & 0xC0) == 0xC0 {
return Some(cursor + 2);
}
cursor = cursor.checked_add(1 + len as usize)?;
}
}
fn read_name(buf: &[u8], start: usize) -> Option<String> {
let mut out = String::new();
let mut cursor = start;
let mut hops = 0;
loop {
if cursor >= buf.len() {
return None;
}
let len = buf[cursor];
if len == 0 {
break;
}
if (len & 0xC0) == 0xC0 {
if cursor + 1 >= buf.len() {
return None;
}
let pointer = ((len & 0x3F) as usize) << 8 | (buf[cursor + 1] as usize);
hops += 1;
if hops > 8 {
return None;
}
cursor = pointer;
continue;
}
cursor += 1;
let end = cursor.checked_add(len as usize)?;
if end > buf.len() {
return None;
}
if !out.is_empty() {
out.push('.');
}
out.push_str(std::str::from_utf8(&buf[cursor..end]).ok()?);
cursor = end;
}
Some(out)
}
fn autodiscover_v1(
agent: &Agent,
url: &str,
email: &str,
auth_header: Option<&str>,
) -> Result<PoxOutcome, EwsError> {
let body = format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\
<Autodiscover xmlns=\"{POX_REQ_NS}\">\
<Request><EMailAddress>{}</EMailAddress>\
<AcceptableResponseSchema>{POX_RESP_NS}</AcceptableResponseSchema>\
</Request></Autodiscover>",
xml_escape(email)
);
let mut req = agent
.post(url)
.header("Content-Type", "text/xml; charset=utf-8");
if let Some(h) = auth_header {
req = req.header("Authorization", h);
}
let mut resp = req
.send(body.as_bytes())
.map_err(|e| EwsError::Transport(format!("v1 POST: {e}")))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(EwsError::AutodiscoverFailed(format!(
"{url} returned http {status}"
)));
}
let bytes = resp
.body_mut()
.with_config()
.limit(2 * 1024 * 1024)
.read_to_vec()
.map_err(|e| EwsError::Malformed(format!("v1 body: {e}")))?;
parse_pox_response(&bytes)
}
fn parse_pox_response(body: &[u8]) -> Result<PoxOutcome, EwsError> {
let mut xml = NsReader::from_reader(body);
xml.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut current: Option<&'static str> = None;
let mut current_type: Option<String> = None;
let mut ews_url: Option<String> = None;
let mut redirect_addr: Option<String> = None;
let mut redirect_url: Option<String> = None;
let mut action: Option<String> = None;
loop {
buf.clear();
let (_, ev) = xml.read_resolved_event_into(&mut buf)?;
match ev {
Event::Start(e) => {
let local = e.local_name().as_ref().to_vec();
if local.eq_ignore_ascii_case(b"Protocol") {
current_type = None;
}
if local.eq_ignore_ascii_case(b"Type") {
current = Some("type");
} else if local.eq_ignore_ascii_case(b"EwsUrl") {
if matches!(current_type.as_deref(), Some("EXPR") | Some("EXCH")) {
current = Some("ewsUrl");
} else {
current = None;
}
} else if local.eq_ignore_ascii_case(b"Action") {
current = Some("action");
} else if local.eq_ignore_ascii_case(b"RedirectAddr") {
current = Some("redirectAddr");
} else if local.eq_ignore_ascii_case(b"RedirectUrl") {
current = Some("redirectUrl");
} else {
current = None;
}
}
Event::End(e) => {
let local = e.local_name().as_ref().to_vec();
if local.eq_ignore_ascii_case(b"Protocol") {
current_type = None;
}
current = None;
}
Event::Text(t) => {
let text = t.decode().map(|c| c.into_owned()).unwrap_or_default();
match current {
Some("type") => current_type = Some(text),
Some("ewsUrl") => ews_url = Some(text),
Some("action") => action = Some(text),
Some("redirectAddr") => redirect_addr = Some(text),
Some("redirectUrl") => redirect_url = Some(text),
_ => {}
}
}
Event::Eof => break,
_ => {}
}
}
if let Some(addr) = redirect_addr.filter(|_| action.as_deref() == Some("redirectAddr")) {
return Ok(PoxOutcome::RedirectAddr(addr));
}
if let Some(url) = redirect_url.filter(|_| action.as_deref() == Some("redirectUrl")) {
return Ok(PoxOutcome::RedirectUrl(url));
}
if let Some(url) = ews_url {
return Ok(PoxOutcome::EwsUrl(url));
}
Ok(PoxOutcome::Failed)
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => out.push(ch),
}
}
out
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_fully_qualified_ews_url() {
assert!(is_fully_qualified_ews_url(
"https://outlook.office365.com/EWS/Exchange.asmx"
));
assert!(is_fully_qualified_ews_url(
"https://exchange.example.com/EWS/Exchange.asmx"
));
assert!(!is_fully_qualified_ews_url(
"http://outlook.office365.com/EWS/Exchange.asmx"
));
assert!(!is_fully_qualified_ews_url(
"https://outlook.office365.com/"
));
}
#[test]
fn pox_response_extracts_ews_url() {
let body = format!(
"<?xml version=\"1.0\"?>\
<Autodiscover xmlns=\"{POX_RESP_NS}\">\
<Response><Account><Protocol><Type>EXPR</Type><EwsUrl>https://srv/EWS/Exchange.asmx</EwsUrl></Protocol></Account></Response></Autodiscover>"
);
match parse_pox_response(body.as_bytes()).unwrap() {
PoxOutcome::EwsUrl(u) => assert_eq!(u, "https://srv/EWS/Exchange.asmx"),
_ => panic!("expected EwsUrl"),
}
}
#[test]
fn pox_response_handles_redirect_addr() {
let body = format!(
"<?xml version=\"1.0\"?>\
<Autodiscover xmlns=\"{POX_RESP_NS}\">\
<Response><Account><Action>redirectAddr</Action><RedirectAddr>[email protected]</RedirectAddr></Account></Response></Autodiscover>"
);
match parse_pox_response(body.as_bytes()).unwrap() {
PoxOutcome::RedirectAddr(a) => assert_eq!(a, "[email protected]"),
_ => panic!("expected RedirectAddr"),
}
}
}
+580
View File
@@ -0,0 +1,580 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use serde_json::{Map, Value, json};
use crate::exchange_ews::parse::{CalendarItemRaw, RawAttendee, RawOccurrence};
use crate::exchange_ews::recurrence::to_jscalendar_rule;
use crate::exchange_ews::tz::windows_to_iana;
pub struct EventValue {
pub data: Value,
pub is_draft: bool,
pub use_default_alerts: bool,
}
pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
let mut event = Map::new();
event.insert("@type".to_owned(), Value::String("Event".to_owned()));
if let Some(uid) = raw.uid.as_ref() {
event.insert("uid".to_owned(), Value::String(uid.clone()));
} else {
event.insert(
"uid".to_owned(),
Value::String(format!(
"vandelay-ews-event-{}",
blake3::hash(raw.id.id.as_bytes()).to_hex()
)),
);
}
if let Some(subject) = raw.subject.as_ref() {
event.insert("title".to_owned(), Value::String(subject.clone()));
}
if let Some(loc) = raw.location.as_ref() {
event.insert(
"locations".to_owned(),
Value::Object(map_singleton(
"1",
json!({"@type": "Location", "name": loc}),
)),
);
}
if let Some(body) = raw.body_text.as_ref() {
event.insert("description".to_owned(), Value::String(body.clone()));
} else if let Some(body) = raw.body_html.as_ref() {
event.insert("description".to_owned(), Value::String(body.clone()));
event.insert(
"descriptionContentType".to_owned(),
Value::String("text/html".to_owned()),
);
}
if let Some(created) = raw.created.as_ref() {
event.insert(
"created".to_owned(),
Value::String(normalise_utc_datetime(created)),
);
}
if let Some(updated) = raw.last_modified.as_ref() {
event.insert(
"updated".to_owned(),
Value::String(normalise_utc_datetime(updated)),
);
}
if !raw.categories.is_empty() {
let mut kw = Map::new();
for c in &raw.categories {
kw.insert(c.to_ascii_lowercase(), Value::Bool(true));
}
event.insert("keywords".to_owned(), Value::Object(kw));
}
let iana = raw
.start_tz
.as_deref()
.map(|tz| windows_to_iana(tz).unwrap_or(tz).to_owned());
if let Some(true) = raw.is_all_day_event {
if let Some(start) = raw.start.as_ref() {
let date_only = start.split('T').next().unwrap_or(start.as_str());
event.insert(
"start".to_owned(),
Value::String(format!("{date_only}T00:00:00")),
);
}
event.insert("showWithoutTime".to_owned(), Value::Bool(true));
let days = match (raw.start.as_ref(), raw.end.as_ref()) {
(Some(s), Some(e)) => all_day_span_days(s, e).max(1),
_ => 1,
};
event.insert("duration".to_owned(), Value::String(format!("P{days}D")));
} else {
if let Some(start) = raw.start.as_ref() {
event.insert(
"start".to_owned(),
Value::String(to_local_datetime_in(start, iana.as_deref())),
);
}
if let (Some(start), Some(end)) = (raw.start.as_ref(), raw.end.as_ref())
&& let Some(dur) = duration_iso8601(start, end)
{
event.insert("duration".to_owned(), Value::String(dur));
}
if let Some(tz) = iana.as_deref() {
event.insert("timeZone".to_owned(), Value::String(tz.to_owned()));
}
}
if let Some(status) = raw.legacy_free_busy_status.as_ref() {
let mapped = match status.as_str() {
"Free" => "free",
_ => "busy",
};
event.insert(
"freeBusyStatus".to_owned(),
Value::String(mapped.to_owned()),
);
}
let mut participants = Map::new();
let mut next_id = 1;
if let Some(email) = raw.organizer_smtp.as_ref() {
let key = next_id.to_string();
next_id += 1;
let cal_addr = format!("mailto:{email}");
event.insert(
"organizerCalendarAddress".to_owned(),
Value::String(cal_addr.clone()),
);
let mut p = Map::new();
p.insert("@type".to_owned(), Value::String("Participant".to_owned()));
p.insert("calendarAddress".to_owned(), Value::String(cal_addr));
p.insert("email".to_owned(), Value::String(email.clone()));
let mut roles = Map::new();
roles.insert("owner".to_owned(), Value::Bool(true));
roles.insert("chair".to_owned(), Value::Bool(true));
p.insert("roles".to_owned(), Value::Object(roles));
if let Some(name) = raw.organizer_name.as_ref() {
p.insert("name".to_owned(), Value::String(name.clone()));
}
participants.insert(key, Value::Object(p));
}
add_attendees(
&mut participants,
&mut next_id,
&raw.required_attendees,
true,
);
add_attendees(
&mut participants,
&mut next_id,
&raw.optional_attendees,
false,
);
if !participants.is_empty() {
event.insert("participants".to_owned(), Value::Object(participants));
}
if let Some(rec) = raw.recurrence.as_ref()
&& let Some(rule) = to_jscalendar_rule(rec)
{
event.insert("recurrenceRules".to_owned(), Value::Array(vec![rule]));
}
let overrides = build_recurrence_overrides(
&raw.modified_occurrences,
&raw.deleted_occurrences,
iana.as_deref(),
);
if let Some(overrides) = overrides {
event.insert("recurrenceOverrides".to_owned(), overrides);
}
EventValue {
data: Value::Object(event),
is_draft: false,
use_default_alerts: false,
}
}
fn add_attendees(
out: &mut Map<String, Value>,
next_id: &mut u32,
attendees: &[RawAttendee],
required: bool,
) {
for att in attendees {
let key = next_id.to_string();
*next_id += 1;
let mut p = Map::new();
p.insert("@type".to_owned(), Value::String("Participant".to_owned()));
if let Some(email) = att.email.as_ref() {
p.insert(
"calendarAddress".to_owned(),
Value::String(format!("mailto:{email}")),
);
p.insert("email".to_owned(), Value::String(email.clone()));
}
if let Some(name) = att.name.as_ref() {
p.insert("name".to_owned(), Value::String(name.clone()));
}
let mut roles = Map::new();
if required {
roles.insert("required".to_owned(), Value::Bool(true));
} else {
roles.insert("optional".to_owned(), Value::Bool(true));
}
p.insert("roles".to_owned(), Value::Object(roles));
if let Some(rt) = att.response_type.as_ref() {
let mapped = match rt.as_str() {
"Accept" => "accepted",
"Tentative" => "tentative",
"Decline" => "declined",
"Organizer" => "accepted",
_ => "needs-action",
};
p.insert(
"participationStatus".to_owned(),
Value::String(mapped.to_owned()),
);
}
out.insert(key, Value::Object(p));
}
}
fn build_recurrence_overrides(
modified: &[RawOccurrence],
deleted: &[RawOccurrence],
iana: Option<&str>,
) -> Option<Value> {
if modified.is_empty() && deleted.is_empty() {
return None;
}
let mut map = Map::new();
for occ in modified {
let Some(key) = occ
.original_start
.as_deref()
.or(occ.start.as_deref())
.map(|s| to_local_datetime_in(s, iana))
else {
continue;
};
let mut o = Map::new();
if let Some(s) = occ.start.as_ref() {
o.insert(
"start".to_owned(),
Value::String(to_local_datetime_in(s, iana)),
);
}
if let (Some(s), Some(e)) = (occ.start.as_ref(), occ.end.as_ref())
&& let Some(dur) = duration_iso8601(s, e)
{
o.insert("duration".to_owned(), Value::String(dur));
}
map.insert(key, Value::Object(o));
}
for occ in deleted {
let Some(key) = occ.start.as_deref().map(|s| to_local_datetime_in(s, iana)) else {
continue;
};
map.insert(key, json!({"excluded": true}));
}
if map.is_empty() {
None
} else {
Some(Value::Object(map))
}
}
fn normalise_utc_datetime(s: &str) -> String {
let trimmed = s.trim();
let stripped = trimmed
.strip_suffix('Z')
.or_else(|| {
trimmed
.rfind(['+', '-'])
.filter(|i| *i >= 10)
.map(|i| &trimmed[..i])
})
.unwrap_or(trimmed);
let (date, time) = match stripped.split_once('T') {
Some((d, t)) => (d, t),
None => return format!("{stripped}T00:00:00Z"),
};
let time_clean = if let Some(dot) = time.find('.') {
&time[..dot]
} else {
time
};
format!("{date}T{time_clean}Z")
}
fn to_local_datetime_in(s: &str, iana: Option<&str>) -> String {
let trimmed = s.trim();
let utc_anchored =
trimmed.ends_with('Z') || trimmed.rfind(['+', '-']).filter(|i| *i >= 10).is_some();
if utc_anchored
&& let Some(tz) = iana
&& let Some(out) = convert_utc_to_local(trimmed, tz)
{
return out;
}
let stripped = trimmed
.strip_suffix('Z')
.or_else(|| {
trimmed
.rfind(['+', '-'])
.filter(|i| *i >= 10)
.map(|i| &trimmed[..i])
})
.unwrap_or(trimmed);
let (date, time) = match stripped.split_once('T') {
Some((d, t)) => (d, t),
None => return format!("{stripped}T00:00:00"),
};
let time_clean = if let Some(dot) = time.find('.') {
&time[..dot]
} else {
time
};
format!("{date}T{time_clean}")
}
fn convert_utc_to_local(s: &str, iana: &str) -> Option<String> {
use chrono::DateTime;
let utc: DateTime<chrono::Utc> = DateTime::parse_from_rfc3339(s)
.ok()?
.with_timezone(&chrono::Utc);
let tz: chrono_tz::Tz = iana.parse().ok()?;
let local = utc.with_timezone(&tz);
Some(local.format("%Y-%m-%dT%H:%M:%S").to_string())
}
fn all_day_span_days(start: &str, end: &str) -> u32 {
fn date_only(s: &str) -> &str {
s.split('T').next().unwrap_or(s)
}
fn ymd(s: &str) -> Option<(i64, u32, u32)> {
let mut p = s.split('-');
let y: i64 = p.next()?.parse().ok()?;
let m: u32 = p.next()?.parse().ok()?;
let d: u32 = p.next()?.parse().ok()?;
Some((y, m, d))
}
fn to_days(y: i64, m: u32, d: u32) -> i64 {
let (mut y, m) = if m <= 2 {
(y - 1, m as i64 + 12)
} else {
(y, m as i64)
};
y += 4800;
let mm = m + 1;
365 * y + y / 4 - y / 100 + y / 400 + 30 * mm + 3 * (mm + 1) / 5 + d as i64 - 32045
}
let (Some(s), Some(e)) = (ymd(date_only(start)), ymd(date_only(end))) else {
return 1;
};
let diff = to_days(e.0, e.1, e.2) - to_days(s.0, s.1, s.2);
if diff <= 0 { 1 } else { diff as u32 }
}
fn duration_iso8601(start: &str, end: &str) -> Option<String> {
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
let s = OffsetDateTime::parse(start, &Rfc3339).ok()?;
let e = OffsetDateTime::parse(end, &Rfc3339).ok()?;
let dur = e - s;
if dur.is_zero() {
return Some("PT0S".to_owned());
}
let total_seconds = dur.whole_seconds();
if total_seconds < 0 {
return None;
}
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
let mut out = String::from("PT");
if hours > 0 {
out.push_str(&format!("{hours}H"));
}
if minutes > 0 {
out.push_str(&format!("{minutes}M"));
}
if seconds > 0 || (hours == 0 && minutes == 0) {
out.push_str(&format!("{seconds}S"));
}
Some(out)
}
fn map_singleton(key: &str, value: Value) -> Map<String, Value> {
let mut m = Map::new();
m.insert(key.to_owned(), value);
m
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exchange_ews::parse::{RecurrencePattern, RecurrenceRange};
#[test]
fn simple_event_round_trip_converts_utc_to_local_wall_clock() {
let raw = CalendarItemRaw {
uid: Some("uid-1".to_owned()),
subject: Some("Meeting".to_owned()),
start: Some("2025-06-15T14:00:00Z".to_owned()),
end: Some("2025-06-15T15:00:00Z".to_owned()),
location: Some("HQ".to_owned()),
start_tz: Some("Pacific Standard Time".to_owned()),
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
assert_eq!(v["@type"], "Event");
assert_eq!(v["uid"], "uid-1");
assert_eq!(v["title"], "Meeting");
assert_eq!(v["start"], "2025-06-15T07:00:00");
assert_eq!(v["duration"], "PT1H");
assert_eq!(v["timeZone"], "America/Los_Angeles");
assert_eq!(v["locations"]["1"]["name"], "HQ");
}
#[test]
fn naive_start_without_timezone_stays_floating() {
let raw = CalendarItemRaw {
uid: Some("uid-naive".to_owned()),
start: Some("2025-06-15T14:00:00".to_owned()),
end: Some("2025-06-15T15:00:00".to_owned()),
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
assert_eq!(v["start"], "2025-06-15T14:00:00");
assert!(v.get("timeZone").is_none());
}
#[test]
fn unknown_windows_timezone_passes_through_verbatim() {
let raw = CalendarItemRaw {
uid: Some("uid-x".to_owned()),
start: Some("2025-06-15T14:00:00Z".to_owned()),
end: Some("2025-06-15T15:00:00Z".to_owned()),
start_tz: Some("Made Up Time".to_owned()),
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
assert_eq!(v["start"], "2025-06-15T14:00:00");
assert_eq!(v["timeZone"], "Made Up Time");
}
#[test]
fn recurrence_override_key_uses_local_time_in_event_timezone() {
let raw = CalendarItemRaw {
uid: Some("uid-tz".to_owned()),
start: Some("2025-06-15T21:00:00Z".to_owned()),
end: Some("2025-06-15T22:00:00Z".to_owned()),
start_tz: Some("Pacific Standard Time".to_owned()),
recurrence: Some(crate::exchange_ews::parse::RawRecurrence {
pattern: Some(RecurrencePattern::Daily { interval: 1 }),
range: Some(RecurrenceRange::Numbered {
start_date: "2025-06-15".to_owned(),
number_of_occurrences: 3,
}),
}),
deleted_occurrences: vec![RawOccurrence {
item_id: Default::default(),
start: Some("2025-06-16T21:00:00Z".to_owned()),
end: None,
original_start: None,
}],
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
let over = v["recurrenceOverrides"].as_object().unwrap();
assert!(over.contains_key("2025-06-16T14:00:00"));
}
#[test]
fn all_day_event_uses_date_only_start() {
let raw = CalendarItemRaw {
uid: Some("uid-2".to_owned()),
subject: Some("Holiday".to_owned()),
is_all_day_event: Some(true),
start: Some("2025-12-25T00:00:00".to_owned()),
end: Some("2025-12-26T00:00:00".to_owned()),
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
assert_eq!(v["start"], "2025-12-25T00:00:00");
assert_eq!(v["showWithoutTime"], true);
assert_eq!(v["duration"], "P1D");
assert!(v.get("timeZone").is_none());
}
#[test]
fn recurring_master_with_overrides() {
let raw = CalendarItemRaw {
uid: Some("uid-3".to_owned()),
start: Some("2025-06-15T14:00:00Z".to_owned()),
end: Some("2025-06-15T15:00:00Z".to_owned()),
recurrence: Some(crate::exchange_ews::parse::RawRecurrence {
pattern: Some(RecurrencePattern::Daily { interval: 1 }),
range: Some(RecurrenceRange::Numbered {
start_date: "2025-06-15".to_owned(),
number_of_occurrences: 3,
}),
}),
modified_occurrences: vec![RawOccurrence {
item_id: Default::default(),
start: Some("2025-06-16T15:00:00Z".to_owned()),
end: Some("2025-06-16T16:30:00Z".to_owned()),
original_start: Some("2025-06-16T14:00:00Z".to_owned()),
}],
deleted_occurrences: vec![RawOccurrence {
item_id: Default::default(),
start: Some("2025-06-17T14:00:00Z".to_owned()),
end: None,
original_start: None,
}],
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
assert_eq!(v["recurrenceRules"][0]["frequency"], "daily");
assert_eq!(v["recurrenceRules"][0]["count"], 3);
let over = v["recurrenceOverrides"].as_object().unwrap();
assert!(over.contains_key("2025-06-16T14:00:00"));
assert_eq!(over["2025-06-17T14:00:00"]["excluded"], true);
}
#[test]
fn multi_day_all_day_spans_correct_number_of_days() {
let raw = CalendarItemRaw {
uid: Some("uid-multi".to_owned()),
is_all_day_event: Some(true),
start: Some("2025-07-04T00:00:00".to_owned()),
end: Some("2025-07-07T00:00:00".to_owned()),
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
assert_eq!(v["duration"], "P3D");
assert_eq!(v["showWithoutTime"], true);
}
#[test]
fn attendees_get_required_or_optional_role() {
let raw = CalendarItemRaw {
uid: Some("uid-att".to_owned()),
start: Some("2025-06-15T14:00:00Z".to_owned()),
end: Some("2025-06-15T15:00:00Z".to_owned()),
organizer_smtp: Some("alice@x".to_owned()),
required_attendees: vec![crate::exchange_ews::parse::RawAttendee {
email: Some("bob@x".to_owned()),
name: None,
response_type: Some("Accept".to_owned()),
}],
optional_attendees: vec![crate::exchange_ews::parse::RawAttendee {
email: Some("eve@x".to_owned()),
name: None,
response_type: None,
}],
..CalendarItemRaw::default()
};
let v = to_jscalendar(&raw).data;
assert_eq!(v["organizerCalendarAddress"], "mailto:alice@x");
let participants = v["participants"].as_object().unwrap();
let required = participants
.values()
.find(|p| p["email"] == "bob@x")
.unwrap();
assert_eq!(required["roles"]["required"], true);
assert!(required["roles"].get("optional").is_none());
let optional = participants
.values()
.find(|p| p["email"] == "eve@x")
.unwrap();
assert_eq!(optional["roles"]["optional"], true);
}
#[test]
fn all_day_span_helper_is_inclusive_of_start() {
assert_eq!(all_day_span_days("2025-07-04", "2025-07-04"), 1);
assert_eq!(all_day_span_days("2025-07-04", "2025-07-05"), 1);
assert_eq!(all_day_span_days("2025-07-04", "2025-07-07"), 3);
assert_eq!(all_day_span_days("bad", "input"), 1);
}
}
+445
View File
@@ -0,0 +1,445 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use ureq::Agent;
use ureq::config::{Config, RedirectAuthHeaders};
use ureq::tls::{RootCerts, TlsConfig};
use crate::exchange_ews::error::EwsError;
use crate::exchange_ews::parse::{EnvelopeKind, read_envelope_summary};
use crate::exchange_ews::retry::{FaultDisposition, classify_fault, classify_http_status};
use crate::exchange_ews::soap::{EnvelopeOptions, soap_action, wrap_envelope};
use crate::exchange_ews::types::ServerVersion;
use crate::jmap::http::{Auth, RetryPolicy, retry_after_header};
use crate::jmap::retry::{self, Disposition, RateLimitState};
use crate::logging::{LEVEL_BODIES, LEVEL_DEFAULT, LEVEL_PROGRESS, Logger};
const MAX_BODY: u64 = 2 * 1024 * 1024 * 1024;
const LONG_RETRY_THRESHOLD: Duration = Duration::from_secs(10);
struct Inner {
agent: Agent,
auth: Mutex<Auth>,
impersonated_smtp: Mutex<Option<String>>,
anchor_mailbox: Mutex<Option<String>>,
retry: RetryPolicy,
rate_limit: RateLimitState,
version: Mutex<ServerVersion>,
log_level: AtomicU8,
retries_total: AtomicU64,
retry_after_sleeps: AtomicU64,
soap_calls: AtomicU64,
user_agent: String,
}
#[derive(Clone)]
pub struct EwsClient {
inner: Arc<Inner>,
}
#[derive(Debug, Clone)]
pub struct SoapResponse {
pub body: Vec<u8>,
pub server_version: Option<ServerVersion>,
}
impl EwsClient {
pub fn new(auth: Auth, retry: RetryPolicy, allow_invalid_certs: bool) -> EwsClient {
let config: Config = Config::builder()
.http_status_as_error(false)
.redirect_auth_headers(RedirectAuthHeaders::SameHost)
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.disable_verification(allow_invalid_certs)
.build(),
)
.build();
EwsClient {
inner: Arc::new(Inner {
agent: config.new_agent(),
auth: Mutex::new(auth),
impersonated_smtp: Mutex::new(None),
anchor_mailbox: Mutex::new(None),
retry,
rate_limit: RateLimitState::new(),
version: Mutex::new(ServerVersion::Exchange2013Sp1),
log_level: AtomicU8::new(LEVEL_DEFAULT),
retries_total: AtomicU64::new(0),
retry_after_sleeps: AtomicU64::new(0),
soap_calls: AtomicU64::new(0),
user_agent: format!("vandelay/{}", env!("CARGO_PKG_VERSION")),
}),
}
}
pub fn set_auth(&self, auth: Auth) {
if let Ok(mut g) = self.inner.auth.lock() {
*g = auth;
}
}
pub fn set_anchor_mailbox(&self, smtp: Option<String>) {
if let Ok(mut g) = self.inner.anchor_mailbox.lock() {
*g = smtp;
}
}
pub fn set_impersonation(&self, smtp: Option<String>) {
if let Ok(mut g) = self.inner.impersonated_smtp.lock() {
*g = smtp;
}
}
pub fn set_logger(&self, logger: Logger) {
self.inner
.log_level
.store(logger.level(), Ordering::Relaxed);
}
pub fn logger(&self) -> Logger {
Logger::new(self.inner.log_level.load(Ordering::Relaxed))
}
pub fn set_server_version(&self, version: ServerVersion) {
if let Ok(mut g) = self.inner.version.lock() {
*g = version;
}
}
pub fn server_version(&self) -> ServerVersion {
self.inner
.version
.lock()
.map(|g| *g)
.unwrap_or(ServerVersion::Exchange2013Sp1)
}
pub fn retries_observed(&self) -> u64 {
self.inner.retries_total.load(Ordering::Relaxed)
}
pub fn retry_after_sleeps(&self) -> u64 {
self.inner.retry_after_sleeps.load(Ordering::Relaxed)
}
pub fn soap_calls(&self) -> u64 {
self.inner.soap_calls.load(Ordering::Relaxed)
}
pub fn rate_limit(&self) -> &RateLimitState {
&self.inner.rate_limit
}
pub fn call(&self, url: &str, operation: &str, body: &str) -> Result<SoapResponse, EwsError> {
let envelope = self.wrap(body);
let action = soap_action(operation);
self.inner.soap_calls.fetch_add(1, Ordering::Relaxed);
self.execute(url, &envelope, &action)
}
fn wrap(&self, body: &str) -> String {
let version = self.server_version();
let smtp = self
.inner
.impersonated_smtp
.lock()
.ok()
.and_then(|g| g.clone());
let opts = EnvelopeOptions {
version,
impersonated_smtp: smtp.as_deref(),
};
wrap_envelope(opts, body)
}
fn auth_header(&self) -> String {
let auth = self.inner.auth.lock().ok().map(|g| g.clone());
match auth {
Some(Auth::Basic { user, password }) => {
format!("Basic {}", STANDARD.encode(format!("{user}:{password}")))
}
Some(Auth::Bearer { token }) => format!("Bearer {token}"),
None => String::new(),
}
}
fn anchor_header(&self) -> Option<String> {
self.inner
.anchor_mailbox
.lock()
.ok()
.and_then(|g| g.clone())
}
fn execute(&self, url: &str, body: &str, action: &str) -> Result<SoapResponse, EwsError> {
let logger = self.logger();
let policy = self.inner.retry;
let mut attempt: u32 = 0;
loop {
self.inner.rate_limit.cooldown().wait();
let outcome = self.one_attempt(url, body, action);
match outcome {
AttemptOutcome::Ok {
status,
body: bytes,
retry_after,
} => {
if (200..300).contains(&status) {
self.inner.rate_limit.on_success();
match read_envelope_summary(&bytes) {
Ok(EnvelopeKind::Body { version }) => {
if let Some(v) = version.to_server_version() {
self.set_server_version(v);
}
return Ok(SoapResponse {
body: bytes,
server_version: version.to_server_version(),
});
}
Ok(EnvelopeKind::Fault { fault, version }) => {
if let Some(v) = version.to_server_version() {
self.set_server_version(v);
}
match classify_fault(&fault.response_code) {
FaultDisposition::Fatal => {
return Err(EwsError::SoapFault {
code: fault.response_code,
reason: fault.fault_string,
});
}
FaultDisposition::Auth => {
return Err(EwsError::Auth(fault.fault_string));
}
FaultDisposition::Retryable { delay } => {
attempt += 1;
self.inner.retries_total.fetch_add(1, Ordering::Relaxed);
if attempt > policy.max_retries {
return Err(EwsError::RetriesExhausted(format!(
"{operation} kept returning {code}",
operation = action,
code = fault.response_code
)));
}
let chosen =
self.inner.rate_limit.on_throttle(&policy, delay);
if chosen >= LONG_RETRY_THRESHOLD {
logger.warn(&format!(
"EWS soap fault {} ({}); waiting {}s before retry {}/{}",
fault.response_code,
fault.fault_string,
chosen.as_secs(),
attempt,
policy.max_retries
));
}
if logger.enabled(LEVEL_BODIES) {
eprintln!(
"retry {}/{} {} after {:?} ({})",
attempt,
policy.max_retries,
action,
chosen,
fault.response_code,
);
}
std::thread::sleep(chosen);
continue;
}
}
}
Err(e) => return Err(e),
}
}
if status == 401 {
return Err(EwsError::Auth(format!(
"server returned 401: {}",
truncate(&bytes)
)));
}
if status == 403 {
return Err(EwsError::HttpStatus {
status: 403,
body: truncate(&bytes),
});
}
match classify_http_status(status) {
Disposition::Fatal => {
return Err(EwsError::HttpStatus {
status,
body: truncate(&bytes),
});
}
Disposition::Retryable => {
attempt += 1;
self.inner.retries_total.fetch_add(1, Ordering::Relaxed);
if attempt > policy.max_retries {
return Err(EwsError::RetriesExhausted(format!(
"{action} kept returning http {status}"
)));
}
if retry_after.is_some() {
self.inner
.retry_after_sleeps
.fetch_add(1, Ordering::Relaxed);
}
let chosen = self.inner.rate_limit.on_throttle(&policy, retry_after);
if chosen >= LONG_RETRY_THRESHOLD {
logger.warn(&format!(
"EWS {action} rate-limited (http {status}); waiting {}s",
chosen.as_secs()
));
}
if logger.enabled(LEVEL_PROGRESS) {
eprintln!(
"retry {}/{} (http {status})",
attempt, policy.max_retries
);
}
std::thread::sleep(chosen);
}
}
}
AttemptOutcome::Transport(err) => {
attempt += 1;
self.inner.retries_total.fetch_add(1, Ordering::Relaxed);
if attempt > policy.max_retries {
return Err(err);
}
let delay = retry::backoff_delay(&policy, attempt);
if delay >= LONG_RETRY_THRESHOLD {
logger.warn(&format!(
"transient transport failure ({err}); waiting {}s before retry {}/{}",
delay.as_secs(),
attempt,
policy.max_retries
));
}
std::thread::sleep(delay);
}
}
}
}
fn one_attempt(&self, url: &str, body: &str, action: &str) -> AttemptOutcome {
let mut req = self
.inner
.agent
.post(url)
.header("Authorization", self.auth_header())
.header("Content-Type", "text/xml; charset=utf-8")
.header("Accept", "text/xml, application/soap+xml, application/xml")
.header("Accept-Encoding", "gzip")
.header("SOAPAction", action.to_owned())
.header("User-Agent", self.inner.user_agent.as_str());
if let Some(anchor) = self.anchor_header() {
req = req.header("X-AnchorMailbox", anchor);
}
let result = req.send(body.as_bytes());
match result {
Ok(mut resp) => {
let status = resp.status().as_u16();
let retry_after = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(retry_after_header);
match resp.body_mut().with_config().limit(MAX_BODY).read_to_vec() {
Ok(bytes) => AttemptOutcome::Ok {
status,
body: bytes,
retry_after,
},
Err(e) => AttemptOutcome::Transport(EwsError::Transport(format!(
"reading response body: {e}"
))),
}
}
Err(e) => AttemptOutcome::Transport(map_ureq_error(e)),
}
}
}
enum AttemptOutcome {
Ok {
status: u16,
body: Vec<u8>,
retry_after: Option<Duration>,
},
Transport(EwsError),
}
fn map_ureq_error(err: ureq::Error) -> EwsError {
match err {
ureq::Error::Io(e) => EwsError::Transport(format!("io: {e}")),
ureq::Error::Timeout(t) => EwsError::Transport(format!("timeout: {t}")),
ureq::Error::HostNotFound => EwsError::Transport("host not found".to_owned()),
ureq::Error::ConnectionFailed => EwsError::Transport("connection failed".to_owned()),
ureq::Error::BodyStalled => EwsError::Transport("body stalled".to_owned()),
ureq::Error::Tls(m) => EwsError::Transport(format!("tls: {m}")),
ureq::Error::TooManyRedirects => EwsError::Connect("too many redirects".to_owned()),
ureq::Error::RedirectFailed => EwsError::Connect("redirect failed".to_owned()),
ureq::Error::TlsRequired => {
EwsError::Connect("server requires TLS but transport is unsecured".to_owned())
}
other => EwsError::Connect(other.to_string()),
}
}
fn truncate(body: &[u8]) -> String {
let text = String::from_utf8_lossy(body);
if text.len() <= 512 {
return text.into_owned();
}
let end = text.floor_char_boundary(512);
format!("{}...", &text[..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_constructs_with_defaults() {
let c = EwsClient::new(
Auth::Bearer { token: "t".into() },
RetryPolicy::new(3),
false,
);
assert_eq!(c.server_version(), ServerVersion::Exchange2013Sp1);
assert_eq!(c.retries_observed(), 0);
assert_eq!(c.retry_after_sleeps(), 0);
}
#[test]
fn server_version_can_be_pinned() {
let c = EwsClient::new(
Auth::Bearer { token: "t".into() },
RetryPolicy::new(0),
false,
);
c.set_server_version(ServerVersion::Exchange2019);
assert_eq!(c.server_version(), ServerVersion::Exchange2019);
}
#[test]
fn anchor_mailbox_round_trips() {
let c = EwsClient::new(
Auth::Bearer { token: "t".into() },
RetryPolicy::new(0),
false,
);
c.set_anchor_mailbox(Some("alice@x".to_owned()));
assert_eq!(c.anchor_header().as_deref(), Some("alice@x"));
}
}
+454
View File
@@ -0,0 +1,454 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use serde_json::{Map, Value, json};
use crate::exchange_ews::parse::{ContactItemRaw, RawContactAddress};
pub fn synthetic_uid(item_id: &str) -> String {
let hash = blake3::hash(item_id.as_bytes()).to_hex();
format!("vandelay-ews-syn-{hash}")
}
pub fn to_jscontact(raw: &ContactItemRaw) -> Value {
let mut card = Map::new();
card.insert("@type".to_owned(), Value::String("Card".to_owned()));
card.insert("version".to_owned(), Value::String("1.0".to_owned()));
if let Some(full) = raw.display_name.as_ref() {
let mut name = Map::new();
name.insert("@type".to_owned(), Value::String("Name".to_owned()));
name.insert("full".to_owned(), Value::String(full.clone()));
let components = name_components(raw);
if !components.is_empty() {
name.insert("components".to_owned(), Value::Array(components));
}
card.insert("name".to_owned(), Value::Object(name));
}
if let Some(n) = raw.nickname.as_ref() {
card.insert(
"nicknames".to_owned(),
Value::Object(map_singleton("1", json!({"@type": "Nickname", "name": n}))),
);
}
let mut organizations: Map<String, Value> = Map::new();
let mut have_org = false;
let mut org_inner: Map<String, Value> = Map::new();
org_inner.insert("@type".to_owned(), Value::String("Organization".to_owned()));
if let Some(co) = raw.company_name.as_ref() {
org_inner.insert("name".to_owned(), Value::String(co.clone()));
have_org = true;
}
let mut org_units: Vec<Value> = Vec::new();
if let Some(dep) = raw.department.as_ref() {
org_units.push(json!({"@type": "OrgUnit", "name": dep}));
}
if let Some(office) = raw.office_location.as_ref() {
org_units.push(json!({"@type": "OrgUnit", "name": office}));
}
if !org_units.is_empty() {
org_inner.insert("units".to_owned(), Value::Array(org_units));
have_org = true;
}
if have_org {
organizations.insert("1".to_owned(), Value::Object(org_inner));
}
let mut next_org_idx = 2u32;
for extra in &raw.companies {
if matches!(raw.company_name.as_deref(), Some(c) if c == extra) {
continue;
}
organizations.insert(
next_org_idx.to_string(),
json!({"@type": "Organization", "name": extra}),
);
next_org_idx += 1;
}
if !organizations.is_empty() {
card.insert("organizations".to_owned(), Value::Object(organizations));
}
if let Some(t) = raw.job_title.as_ref() {
card.insert(
"titles".to_owned(),
Value::Object(map_singleton("1", json!({"@type": "Title", "name": t}))),
);
}
if !raw.emails.is_empty() {
let mut map = Map::new();
for (i, (key, addr)) in raw.emails.iter().enumerate() {
let entry_id = format!("{}", i + 1);
let mut entry = Map::new();
entry.insert("@type".to_owned(), Value::String("EmailAddress".to_owned()));
entry.insert("address".to_owned(), Value::String(addr.clone()));
entry.insert("contexts".to_owned(), email_contexts(key));
map.insert(entry_id, Value::Object(entry));
}
card.insert("emails".to_owned(), Value::Object(map));
}
if !raw.phones.is_empty() {
let mut map = Map::new();
for (i, (key, num)) in raw.phones.iter().enumerate() {
let entry_id = format!("{}", i + 1);
let mut entry = Map::new();
entry.insert("@type".to_owned(), Value::String("Phone".to_owned()));
entry.insert("number".to_owned(), Value::String(num.clone()));
entry.insert("contexts".to_owned(), phone_contexts(key));
entry.insert("features".to_owned(), phone_features(key));
map.insert(entry_id, Value::Object(entry));
}
card.insert("phones".to_owned(), Value::Object(map));
}
if !raw.ims.is_empty() {
let mut map = Map::new();
for (i, (key, addr)) in raw.ims.iter().enumerate() {
let entry_id = format!("{}", i + 1);
let mut entry = Map::new();
entry.insert(
"@type".to_owned(),
Value::String("OnlineService".to_owned()),
);
entry.insert("user".to_owned(), Value::String(addr.clone()));
if let Some(service) = im_service_from_key(key) {
entry.insert("service".to_owned(), Value::String(service));
}
map.insert(entry_id, Value::Object(entry));
}
card.insert("onlineServices".to_owned(), Value::Object(map));
}
if !raw.addresses.is_empty() {
let pref_key = raw
.postal_address_index
.as_deref()
.map(str::to_ascii_lowercase);
let mut map = Map::new();
for (i, addr) in raw.addresses.iter().enumerate() {
let entry_id = format!("{}", i + 1);
let is_pref = matches!(&pref_key, Some(p) if !p.is_empty() && p == &addr.key.to_ascii_lowercase());
map.insert(entry_id, address_to_jscontact(addr, is_pref));
}
card.insert("addresses".to_owned(), Value::Object(map));
}
if let Some(url) = raw.url.as_ref() {
card.insert(
"links".to_owned(),
Value::Object(map_singleton("1", json!({"@type": "Link", "uri": url}))),
);
}
if let Some(bday) = raw.birthday.as_ref()
&& let Some(partial) = partial_date(bday)
{
card.insert(
"anniversaries".to_owned(),
Value::Object(map_singleton(
"birth",
json!({"@type": "Anniversary", "kind": "birth", "date": partial}),
)),
);
}
if let Some(anniv) = raw.wedding_anniversary.as_ref()
&& let Some(partial) = partial_date(anniv)
{
let key = "wedding";
let entry = json!({"@type": "Anniversary", "kind": "wedding", "date": partial});
if let Some(map) = card.get_mut("anniversaries").and_then(Value::as_object_mut) {
map.insert(key.to_owned(), entry);
} else {
card.insert(
"anniversaries".to_owned(),
Value::Object(map_singleton(key, entry)),
);
}
}
if !raw.categories.is_empty() {
let mut keywords = Map::new();
for cat in &raw.categories {
keywords.insert(cat.to_ascii_lowercase(), Value::Bool(true));
}
card.insert("keywords".to_owned(), Value::Object(keywords));
}
if let Some(notes) = raw.notes.as_ref() {
card.insert(
"notes".to_owned(),
Value::Object(map_singleton("1", json!({"@type": "Note", "note": notes}))),
);
}
let mut related: Map<String, Value> = Map::new();
if let Some(v) = raw.spouse.as_ref() {
related.insert(
v.clone(),
json!({"@type": "Relation", "relation": {"spouse": true}}),
);
}
for child in &raw.children {
related.insert(
child.clone(),
json!({"@type": "Relation", "relation": {"child": true}}),
);
}
if let Some(v) = raw.manager.as_ref() {
related.insert(
v.clone(),
json!({"@type": "Relation", "relation": {"x-manager": true}}),
);
}
if let Some(v) = raw.assistant.as_ref() {
related.insert(
v.clone(),
json!({"@type": "Relation", "relation": {"x-assistant": true}}),
);
}
if !related.is_empty() {
card.insert("relatedTo".to_owned(), Value::Object(related));
}
Value::Object(card)
}
fn name_components(raw: &ContactItemRaw) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
if let Some(v) = raw.given_name.as_ref() {
out.push(json!({"@type": "NameComponent", "kind": "given", "value": v}));
}
if let Some(v) = raw.middle_name.as_ref() {
out.push(json!({"@type": "NameComponent", "kind": "given2", "value": v}));
}
if let Some(v) = raw.surname.as_ref() {
out.push(json!({"@type": "NameComponent", "kind": "surname", "value": v}));
}
if let Some(v) = raw.generation.as_ref() {
out.push(json!({"@type": "NameComponent", "kind": "generation", "value": v}));
}
out
}
fn email_contexts(key: &str) -> Value {
let mut o = Map::new();
let lower = key.to_ascii_lowercase();
if lower.contains("business") || lower.contains("work") {
o.insert("work".to_owned(), Value::Bool(true));
} else if lower.contains("home") {
o.insert("private".to_owned(), Value::Bool(true));
}
Value::Object(o)
}
fn phone_contexts(key: &str) -> Value {
let mut o = Map::new();
let lower = key.to_ascii_lowercase();
if lower.starts_with("business") || lower.starts_with("company") {
o.insert("work".to_owned(), Value::Bool(true));
} else if lower.starts_with("home") || lower.starts_with("mobile") {
o.insert("private".to_owned(), Value::Bool(true));
}
Value::Object(o)
}
fn phone_features(key: &str) -> Value {
let mut o = Map::new();
let lower = key.to_ascii_lowercase();
if lower.contains("mobile") {
o.insert("mobile".to_owned(), Value::Bool(true));
} else if lower.contains("fax") {
o.insert("fax".to_owned(), Value::Bool(true));
} else if lower.contains("pager") {
o.insert("pager".to_owned(), Value::Bool(true));
} else {
o.insert("voice".to_owned(), Value::Bool(true));
}
Value::Object(o)
}
fn address_to_jscontact(addr: &RawContactAddress, is_pref: bool) -> Value {
let mut o = Map::new();
o.insert("@type".to_owned(), Value::String("Address".to_owned()));
let mut components: Vec<Value> = Vec::new();
if let Some(s) = addr.street.as_ref() {
components.push(json!({"@type": "AddressComponent", "kind": "name", "value": s}));
}
if let Some(s) = addr.city.as_ref() {
components.push(json!({"@type": "AddressComponent", "kind": "locality", "value": s}));
}
if let Some(s) = addr.state.as_ref() {
components.push(json!({"@type": "AddressComponent", "kind": "region", "value": s}));
}
if let Some(s) = addr.postal_code.as_ref() {
components.push(json!({"@type": "AddressComponent", "kind": "postcode", "value": s}));
}
if let Some(s) = addr.country.as_ref() {
components.push(json!({"@type": "AddressComponent", "kind": "country", "value": s}));
}
if !components.is_empty() {
o.insert("components".to_owned(), Value::Array(components));
}
let mut contexts = Map::new();
let lower = addr.key.to_ascii_lowercase();
if lower == "business" || lower == "work" {
contexts.insert("work".to_owned(), Value::Bool(true));
} else if lower == "home" {
contexts.insert("private".to_owned(), Value::Bool(true));
}
if !contexts.is_empty() {
o.insert("contexts".to_owned(), Value::Object(contexts));
}
if is_pref {
o.insert("pref".to_owned(), Value::from(1u32));
}
Value::Object(o)
}
fn im_service_from_key(key: &str) -> Option<String> {
let trimmed: String = key.chars().filter(|c| !c.is_ascii_digit()).collect();
let canonical = trimmed.trim_start_matches("ImAddress");
if canonical.is_empty() {
return None;
}
Some(canonical.to_owned())
}
fn partial_date(s: &str) -> Option<Value> {
let head = s.split('T').next().unwrap_or(s);
let mut parts = head.splitn(3, '-');
let year: u32 = parts.next()?.parse().ok()?;
let month: u32 = parts.next()?.parse().ok()?;
let day: u32 = parts.next()?.parse().ok()?;
let mut o = Map::new();
o.insert("@type".to_owned(), Value::String("PartialDate".to_owned()));
if year > 0 {
o.insert("year".to_owned(), Value::from(year));
}
o.insert("month".to_owned(), Value::from(month));
o.insert("day".to_owned(), Value::from(day));
Some(Value::Object(o))
}
fn map_singleton(key: &str, value: Value) -> Map<String, Value> {
let mut m = Map::new();
m.insert(key.to_owned(), value);
m
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_contact_round_trip() {
let raw = ContactItemRaw {
display_name: Some("Alice Doe".to_owned()),
given_name: Some("Alice".to_owned()),
surname: Some("Doe".to_owned()),
emails: vec![("EmailAddress1".to_owned(), "alice@x".to_owned())],
phones: vec![("BusinessPhone1".to_owned(), "+1".to_owned())],
..ContactItemRaw::default()
};
let v = to_jscontact(&raw);
assert_eq!(v["@type"], "Card");
assert_eq!(v["name"]["full"], "Alice Doe");
let comps = v["name"]["components"].as_array().unwrap();
assert_eq!(comps[0]["kind"], "given");
assert_eq!(comps[0]["value"], "Alice");
assert_eq!(v["emails"]["1"]["address"], "alice@x");
assert_eq!(v["phones"]["1"]["contexts"]["work"], true);
}
#[test]
fn synthetic_uid_is_stable() {
let u1 = synthetic_uid("ITEM-1");
let u2 = synthetic_uid("ITEM-1");
assert_eq!(u1, u2);
assert_ne!(u1, synthetic_uid("ITEM-2"));
assert!(u1.starts_with("vandelay-ews-syn-"));
}
#[test]
fn anniversaries_combine_birth_and_wedding() {
let raw = ContactItemRaw {
display_name: Some("X".to_owned()),
birthday: Some("1990-04-15T00:00:00Z".to_owned()),
wedding_anniversary: Some("2015-08-22T00:00:00".to_owned()),
..ContactItemRaw::default()
};
let v = to_jscontact(&raw);
assert_eq!(v["anniversaries"]["birth"]["kind"], "birth");
assert_eq!(v["anniversaries"]["birth"]["date"]["month"], 4);
assert_eq!(v["anniversaries"]["wedding"]["kind"], "wedding");
}
#[test]
fn office_location_is_added_as_org_unit() {
let raw = ContactItemRaw {
display_name: Some("X".to_owned()),
company_name: Some("Initech".to_owned()),
department: Some("R&D".to_owned()),
office_location: Some("Bldg 4".to_owned()),
..ContactItemRaw::default()
};
let v = to_jscontact(&raw);
let units = v["organizations"]["1"]["units"].as_array().unwrap();
assert_eq!(units[0]["name"], "R&D");
assert_eq!(units[1]["name"], "Bldg 4");
}
#[test]
fn extra_companies_become_additional_organizations() {
let raw = ContactItemRaw {
display_name: Some("X".to_owned()),
company_name: Some("Initech".to_owned()),
companies: vec!["Initech".to_owned(), "Initrode".to_owned()],
..ContactItemRaw::default()
};
let v = to_jscontact(&raw);
assert_eq!(v["organizations"]["1"]["name"], "Initech");
assert_eq!(v["organizations"]["2"]["name"], "Initrode");
}
#[test]
fn postal_address_index_sets_pref_on_matching_entry() {
let raw = ContactItemRaw {
display_name: Some("X".to_owned()),
addresses: vec![
RawContactAddress {
key: "Home".to_owned(),
street: Some("1 St".to_owned()),
..Default::default()
},
RawContactAddress {
key: "Business".to_owned(),
street: Some("2 Av".to_owned()),
..Default::default()
},
],
postal_address_index: Some("Business".to_owned()),
..ContactItemRaw::default()
};
let v = to_jscontact(&raw);
assert!(v["addresses"]["1"].get("pref").is_none());
assert_eq!(v["addresses"]["2"]["pref"], 1);
}
#[test]
fn im_service_extracted_from_key() {
let raw = ContactItemRaw {
display_name: Some("X".to_owned()),
ims: vec![("ImAddress1".to_owned(), "alice@msn".to_owned())],
..ContactItemRaw::default()
};
let v = to_jscontact(&raw);
assert_eq!(v["onlineServices"]["1"]["user"], "alice@msn");
assert!(v["onlineServices"]["1"].get("service").is_none());
}
#[test]
fn relations_use_x_prefix_for_manager_and_assistant() {
let raw = ContactItemRaw {
display_name: Some("X".to_owned()),
manager: Some("Boss".to_owned()),
assistant: Some("Asst".to_owned()),
..ContactItemRaw::default()
};
let v = to_jscontact(&raw);
assert_eq!(v["relatedTo"]["Boss"]["relation"]["x-manager"], true);
assert_eq!(v["relatedTo"]["Asst"]["relation"]["x-assistant"], true);
}
}
+86
View File
@@ -0,0 +1,86 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use crate::error::Error;
use crate::exchange_ews::types::ResponseCode;
use crate::jmap::error::JmapError;
#[derive(Debug, thiserror::Error)]
pub enum EwsError {
#[error("transport: {0}")]
Transport(String),
#[error("connection: {0}")]
Connect(String),
#[error("authentication rejected: {0}")]
Auth(String),
#[error("http {status}: {body}")]
HttpStatus { status: u16, body: String },
#[error("retries exhausted: {0}")]
RetriesExhausted(String),
#[error("soap fault {code}: {reason}")]
SoapFault { code: ResponseCode, reason: String },
#[error("malformed EWS response: {0}")]
Malformed(String),
#[error("EWS schema unsupported: {0}")]
Unsupported(String),
#[error("autodiscover failed: {0}")]
AutodiscoverFailed(String),
#[error("autodiscover redirect loop")]
AutodiscoverLoop,
#[error("oauth: {0}")]
OAuth(String),
#[error("xml: {0}")]
Xml(String),
}
impl From<quick_xml::Error> for EwsError {
fn from(value: quick_xml::Error) -> Self {
EwsError::Xml(value.to_string())
}
}
impl From<JmapError> for EwsError {
fn from(value: JmapError) -> Self {
match value {
JmapError::Transport(m) => EwsError::Transport(m),
JmapError::Connect(m) => EwsError::Connect(m),
JmapError::Auth(m) => EwsError::Auth(m),
JmapError::HttpStatus { status, body } => EwsError::HttpStatus { status, body },
JmapError::RetriesExhausted(m) => EwsError::RetriesExhausted(m),
JmapError::Malformed(m) => EwsError::Malformed(m),
other => EwsError::Connect(other.to_string()),
}
}
}
impl From<EwsError> for Error {
fn from(value: EwsError) -> Self {
match value {
EwsError::Auth(m) => Error::Connection(format!("authentication rejected: {m}")),
EwsError::Connect(m) | EwsError::Transport(m) => Error::Connection(m),
EwsError::AutodiscoverFailed(m) => {
Error::Connection(format!("autodiscover failed: {m}"))
}
EwsError::AutodiscoverLoop => {
Error::Connection("autodiscover redirect loop".to_owned())
}
EwsError::OAuth(m) => Error::Connection(format!("oauth: {m}")),
EwsError::Unsupported(m) => Error::Usage(m),
other => Error::Connection(other.to_string()),
}
}
}
+26
View File
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
pub mod autodiscover;
pub mod calendar_map;
pub mod client;
pub mod contact_map;
pub mod error;
pub mod oauth;
pub mod parse;
pub mod recurrence;
pub mod retry;
pub mod soap;
pub mod types;
pub mod xml;
pub use crate::exchange::tz;
pub use client::EwsClient;
pub use error::EwsError;
pub use types::{
DistinguishedFolderId, FolderId, ItemId, MailboxKind, ResponseCode, ServerVersion,
};
+373
View File
@@ -0,0 +1,373 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io::{self, Write};
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde_json::Value;
use ureq::config::Config;
use ureq::tls::{RootCerts, TlsConfig};
use crate::exchange_ews::error::EwsError;
pub const SCOPE_APP_ONLY: &str = "https://outlook.office365.com/.default";
pub const SCOPE_DELEGATED: &str =
"https://outlook.office365.com/EWS.AccessAsUser.All offline_access";
#[derive(Debug, Clone)]
pub struct AcquiredToken {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: Option<u64>,
pub tenant_id: Option<String>,
pub upn: Option<String>,
pub name: Option<String>,
}
#[derive(Debug, Clone)]
pub enum OAuthFlow {
PreAcquired {
token: String,
},
ClientCredentials {
tenant: String,
client_id: String,
client_secret: String,
},
DeviceCode {
tenant: String,
client_id: String,
},
}
pub fn acquire(flow: &OAuthFlow, allow_invalid_certs: bool) -> Result<AcquiredToken, EwsError> {
match flow {
OAuthFlow::PreAcquired { token } => {
let claims = decode_jwt_claims(token).unwrap_or_default();
Ok(AcquiredToken {
access_token: token.clone(),
refresh_token: None,
expires_in: None,
tenant_id: claims.tenant_id,
upn: claims.upn,
name: claims.name,
})
}
OAuthFlow::ClientCredentials {
tenant,
client_id,
client_secret,
} => client_credentials(tenant, client_id, client_secret, allow_invalid_certs),
OAuthFlow::DeviceCode { tenant, client_id } => {
device_code_flow(tenant, client_id, allow_invalid_certs)
}
}
}
#[derive(Debug, Clone, Default)]
pub struct JwtClaims {
pub tenant_id: Option<String>,
pub upn: Option<String>,
pub name: Option<String>,
pub exp: Option<u64>,
}
pub fn decode_jwt_claims(token: &str) -> Option<JwtClaims> {
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
let bytes = URL_SAFE_NO_PAD
.decode(payload)
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(payload))
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload))
.ok()?;
let value: Value = serde_json::from_slice(&bytes).ok()?;
Some(JwtClaims {
tenant_id: value.get("tid").and_then(Value::as_str).map(str::to_owned),
upn: value
.get("upn")
.and_then(Value::as_str)
.or_else(|| value.get("preferred_username").and_then(Value::as_str))
.map(str::to_owned),
name: value.get("name").and_then(Value::as_str).map(str::to_owned),
exp: value.get("exp").and_then(Value::as_u64),
})
}
fn token_endpoint(tenant: &str) -> String {
format!("https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token")
}
fn device_code_endpoint(tenant: &str) -> String {
format!("https://login.microsoftonline.com/{tenant}/oauth2/v2.0/devicecode")
}
fn build_agent(allow_invalid_certs: bool) -> ureq::Agent {
let config: Config = Config::builder()
.http_status_as_error(false)
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.disable_verification(allow_invalid_certs)
.build(),
)
.build();
config.new_agent()
}
fn client_credentials(
tenant: &str,
client_id: &str,
client_secret: &str,
allow_invalid_certs: bool,
) -> Result<AcquiredToken, EwsError> {
let agent = build_agent(allow_invalid_certs);
let body = form_encode(&[
("client_id", client_id),
("client_secret", client_secret),
("scope", SCOPE_APP_ONLY),
("grant_type", "client_credentials"),
]);
let endpoint = token_endpoint(tenant);
let resp = agent
.post(&endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
.send(body.as_bytes())
.map_err(|e| EwsError::OAuth(format!("token endpoint: {e}")))?;
parse_token_response(resp)
}
fn device_code_flow(
tenant: &str,
client_id: &str,
allow_invalid_certs: bool,
) -> Result<AcquiredToken, EwsError> {
let agent = build_agent(allow_invalid_certs);
let body = form_encode(&[("client_id", client_id), ("scope", SCOPE_DELEGATED)]);
let endpoint = device_code_endpoint(tenant);
let mut resp = agent
.post(&endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
.send(body.as_bytes())
.map_err(|e| EwsError::OAuth(format!("device code endpoint: {e}")))?;
let json: Value = resp
.body_mut()
.read_json()
.map_err(|e| EwsError::OAuth(format!("device code body: {e}")))?;
let device_code = json
.get("device_code")
.and_then(Value::as_str)
.ok_or_else(|| EwsError::OAuth("device_code missing".to_owned()))?
.to_owned();
let user_code = json.get("user_code").and_then(Value::as_str).unwrap_or("?");
let verify_uri = json
.get("verification_uri")
.and_then(Value::as_str)
.unwrap_or("https://microsoft.com/devicelogin");
let interval = json.get("interval").and_then(Value::as_u64).unwrap_or(5);
let message = json.get("message").and_then(Value::as_str);
if let Some(m) = message {
eprintln!("{m}");
} else {
eprintln!("To sign in, open {verify_uri} and enter the code {user_code}");
}
io::stdout()
.flush()
.map_err(|e| EwsError::OAuth(format!("flushing stdout: {e}")))?;
let endpoint = token_endpoint(tenant);
let mut delay = Duration::from_secs(interval);
let deadline = std::time::Instant::now()
+ Duration::from_secs(
json.get("expires_in")
.and_then(Value::as_u64)
.unwrap_or(900),
);
loop {
std::thread::sleep(delay);
if std::time::Instant::now() >= deadline {
return Err(EwsError::OAuth(
"device code expired before the user completed sign-in".to_owned(),
));
}
let body = form_encode(&[
("client_id", client_id),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("device_code", &device_code),
]);
let mut resp = agent
.post(&endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
.send(body.as_bytes())
.map_err(|e| EwsError::OAuth(format!("device code polling: {e}")))?;
let status = resp.status().as_u16();
if (200..300).contains(&status) {
return parse_token_response(resp);
}
let payload: Value = resp.body_mut().read_json().unwrap_or(Value::Null);
let error = payload.get("error").and_then(Value::as_str).unwrap_or("");
match error {
"authorization_pending" => continue,
"slow_down" => {
delay = delay.saturating_add(Duration::from_secs(5));
continue;
}
"expired_token" => {
return Err(EwsError::OAuth(
"device code expired; restart the import".to_owned(),
));
}
"" => {
return Err(EwsError::OAuth(format!(
"device code polling: http {status}"
)));
}
other => {
let desc = payload
.get("error_description")
.and_then(Value::as_str)
.unwrap_or("");
return Err(EwsError::OAuth(format!(
"device code polling: {other}: {desc}"
)));
}
}
}
}
fn parse_token_response(
mut resp: ureq::http::Response<ureq::Body>,
) -> Result<AcquiredToken, EwsError> {
let status = resp.status().as_u16();
let json: Value = resp
.body_mut()
.read_json()
.map_err(|e| EwsError::OAuth(format!("token body: {e}")))?;
if !(200..300).contains(&status) {
let desc = json
.get("error_description")
.and_then(Value::as_str)
.unwrap_or("(no error_description)");
return Err(EwsError::OAuth(format!("http {status}: {desc}")));
}
let access = json
.get("access_token")
.and_then(Value::as_str)
.ok_or_else(|| EwsError::OAuth("access_token missing".to_owned()))?
.to_owned();
let refresh_token = json
.get("refresh_token")
.and_then(Value::as_str)
.map(str::to_owned);
let expires_in = json.get("expires_in").and_then(Value::as_u64);
let claims = decode_jwt_claims(&access).unwrap_or_default();
Ok(AcquiredToken {
access_token: access,
refresh_token,
expires_in,
tenant_id: claims.tenant_id,
upn: claims.upn,
name: claims.name,
})
}
pub fn refresh_with_token(
tenant: &str,
client_id: &str,
refresh_token: &str,
allow_invalid_certs: bool,
) -> Result<AcquiredToken, EwsError> {
let agent = build_agent(allow_invalid_certs);
let body = form_encode(&[
("client_id", client_id),
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("scope", SCOPE_DELEGATED),
]);
let endpoint = token_endpoint(tenant);
let resp = agent
.post(&endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
.send(body.as_bytes())
.map_err(|e| EwsError::OAuth(format!("refresh token endpoint: {e}")))?;
parse_token_response(resp)
}
fn form_encode(pairs: &[(&str, &str)]) -> String {
let mut out = String::new();
for (i, (k, v)) in pairs.iter().enumerate() {
if i > 0 {
out.push('&');
}
out.push_str(&urlencode(k));
out.push('=');
out.push_str(&urlencode(v));
}
out
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
fn make_jwt(tid: &str, upn: &str, exp: u64) -> String {
let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}");
let claims = format!(r#"{{"tid":"{tid}","upn":"{upn}","exp":{exp}}}"#);
let payload = URL_SAFE_NO_PAD.encode(claims.as_bytes());
format!("{header}.{payload}.")
}
#[test]
fn jwt_decoder_extracts_tid_and_upn() {
let token = make_jwt("tenant-1", "[email protected]", 9999999999);
let claims = decode_jwt_claims(&token).unwrap();
assert_eq!(claims.tenant_id.as_deref(), Some("tenant-1"));
assert_eq!(claims.upn.as_deref(), Some("[email protected]"));
assert_eq!(claims.exp, Some(9999999999));
}
#[test]
fn malformed_token_returns_none() {
assert!(decode_jwt_claims("garbage").is_none());
assert!(decode_jwt_claims("only.two").is_none());
}
#[test]
fn pre_acquired_flow_decodes_claims() {
let token = make_jwt("t-2", "bob@x", 9999999999);
let acq = acquire(
&OAuthFlow::PreAcquired {
token: token.clone(),
},
false,
)
.unwrap();
assert_eq!(acq.access_token, token);
assert_eq!(acq.tenant_id.as_deref(), Some("t-2"));
assert_eq!(acq.upn.as_deref(), Some("bob@x"));
}
#[test]
fn urlencode_handles_special_chars() {
assert_eq!(urlencode("a b/c"), "a%20b%2Fc");
assert_eq!(urlencode("a.b-c_d"), "a.b-c_d");
}
}
File diff suppressed because it is too large Load Diff
+309
View File
@@ -0,0 +1,309 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use serde_json::{Map, Value, json};
use crate::exchange_ews::parse::{RawRecurrence, RecurrencePattern, RecurrenceRange};
pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option<Value> {
let mut rule: Map<String, Value> = Map::new();
match raw.pattern.as_ref()? {
RecurrencePattern::Daily { interval } => {
rule.insert("frequency".to_owned(), Value::String("daily".to_owned()));
if *interval > 1 {
rule.insert("interval".to_owned(), Value::from(*interval));
}
}
RecurrencePattern::Weekly {
interval,
days_of_week,
} => {
rule.insert("frequency".to_owned(), Value::String("weekly".to_owned()));
if *interval > 1 {
rule.insert("interval".to_owned(), Value::from(*interval));
}
let days: Vec<Value> = days_of_week
.iter()
.filter_map(|d| day_token(d).map(|t| json!({"day": t})))
.collect();
if !days.is_empty() {
rule.insert("byDay".to_owned(), Value::Array(days));
}
}
RecurrencePattern::AbsoluteMonthly {
interval,
day_of_month,
} => {
rule.insert("frequency".to_owned(), Value::String("monthly".to_owned()));
if *interval > 1 {
rule.insert("interval".to_owned(), Value::from(*interval));
}
rule.insert(
"byMonthDay".to_owned(),
Value::Array(vec![Value::from(*day_of_month)]),
);
}
RecurrencePattern::RelativeMonthly {
interval,
day_of_week_index,
days_of_week,
} => {
rule.insert("frequency".to_owned(), Value::String("monthly".to_owned()));
if *interval > 1 {
rule.insert("interval".to_owned(), Value::from(*interval));
}
let nth = nth_of_period(day_of_week_index);
let days: Vec<Value> = days_of_week
.iter()
.filter_map(|d| {
day_token(d).map(|t| {
let mut o = Map::new();
o.insert("day".to_owned(), Value::String(t.to_owned()));
if let Some(n) = nth {
o.insert("nthOfPeriod".to_owned(), Value::from(n));
}
Value::Object(o)
})
})
.collect();
if !days.is_empty() {
rule.insert("byDay".to_owned(), Value::Array(days));
}
}
RecurrencePattern::AbsoluteYearly {
month,
day_of_month,
} => {
rule.insert("frequency".to_owned(), Value::String("yearly".to_owned()));
if let Some(n) = month_number(month) {
rule.insert(
"byMonth".to_owned(),
Value::Array(vec![Value::String(n.to_string())]),
);
}
rule.insert(
"byMonthDay".to_owned(),
Value::Array(vec![Value::from(*day_of_month)]),
);
}
RecurrencePattern::RelativeYearly {
month,
day_of_week_index,
days_of_week,
} => {
rule.insert("frequency".to_owned(), Value::String("yearly".to_owned()));
if let Some(n) = month_number(month) {
rule.insert(
"byMonth".to_owned(),
Value::Array(vec![Value::String(n.to_string())]),
);
}
let nth = nth_of_period(day_of_week_index);
let days: Vec<Value> = days_of_week
.iter()
.filter_map(|d| {
day_token(d).map(|t| {
let mut o = Map::new();
o.insert("day".to_owned(), Value::String(t.to_owned()));
if let Some(n) = nth {
o.insert("nthOfPeriod".to_owned(), Value::from(n));
}
Value::Object(o)
})
})
.collect();
if !days.is_empty() {
rule.insert("byDay".to_owned(), Value::Array(days));
}
}
}
match raw.range.as_ref() {
Some(RecurrenceRange::NoEnd { .. }) | None => {}
Some(RecurrenceRange::EndDate { end_date, .. }) => {
let local = if end_date.contains('T') {
end_date.clone()
} else {
format!("{end_date}T23:59:59")
};
rule.insert("until".to_owned(), Value::String(local));
}
Some(RecurrenceRange::Numbered {
number_of_occurrences,
..
}) => {
rule.insert("count".to_owned(), Value::from(*number_of_occurrences));
}
}
Some(Value::Object(rule))
}
fn day_token(d: &str) -> Option<&'static str> {
match d.to_ascii_lowercase().as_str() {
"monday" | "mo" => Some("mo"),
"tuesday" | "tu" => Some("tu"),
"wednesday" | "we" => Some("we"),
"thursday" | "th" => Some("th"),
"friday" | "fr" => Some("fr"),
"saturday" | "sa" => Some("sa"),
"sunday" | "su" => Some("su"),
_ => None,
}
}
fn nth_of_period(index: &str) -> Option<i32> {
match index {
"First" => Some(1),
"Second" => Some(2),
"Third" => Some(3),
"Fourth" => Some(4),
"Last" => Some(-1),
_ => None,
}
}
fn month_number(m: &str) -> Option<u32> {
Some(match m {
"January" => 1,
"February" => 2,
"March" => 3,
"April" => 4,
"May" => 5,
"June" => 6,
"July" => 7,
"August" => 8,
"September" => 9,
"October" => 10,
"November" => 11,
"December" => 12,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn daily_interval_round_trips() {
let raw = RawRecurrence {
pattern: Some(RecurrencePattern::Daily { interval: 3 }),
range: Some(RecurrenceRange::Numbered {
start_date: "2025-01-01".to_owned(),
number_of_occurrences: 5,
}),
};
let rule = to_jscalendar_rule(&raw).unwrap();
assert_eq!(rule["frequency"], "daily");
assert_eq!(rule["interval"], 3);
assert_eq!(rule["count"], 5);
}
#[test]
fn relative_monthly_nth_of_period() {
let raw = RawRecurrence {
pattern: Some(RecurrencePattern::RelativeMonthly {
interval: 1,
day_of_week_index: "First".to_owned(),
days_of_week: vec!["Monday".to_owned()],
}),
range: Some(RecurrenceRange::NoEnd {
start_date: "2025-01-01".to_owned(),
}),
};
let rule = to_jscalendar_rule(&raw).unwrap();
assert_eq!(rule["frequency"], "monthly");
let by_day = rule["byDay"].as_array().unwrap();
assert_eq!(by_day[0]["day"], "mo");
assert_eq!(by_day[0]["nthOfPeriod"], 1);
}
#[test]
fn relative_yearly_last_friday_in_june() {
let raw = RawRecurrence {
pattern: Some(RecurrencePattern::RelativeYearly {
month: "June".to_owned(),
day_of_week_index: "Last".to_owned(),
days_of_week: vec!["Friday".to_owned()],
}),
range: Some(RecurrenceRange::NoEnd {
start_date: "2020-01-01".to_owned(),
}),
};
let rule = to_jscalendar_rule(&raw).unwrap();
assert_eq!(rule["frequency"], "yearly");
assert_eq!(rule["byMonth"][0], "6");
assert_eq!(rule["byDay"][0]["day"], "fr");
assert_eq!(rule["byDay"][0]["nthOfPeriod"], -1);
}
#[test]
fn absolute_monthly_translates_to_by_month_day() {
let raw = RawRecurrence {
pattern: Some(RecurrencePattern::AbsoluteMonthly {
interval: 2,
day_of_month: 15,
}),
range: Some(RecurrenceRange::NoEnd {
start_date: "2025-01-15".to_owned(),
}),
};
let rule = to_jscalendar_rule(&raw).unwrap();
assert_eq!(rule["frequency"], "monthly");
assert_eq!(rule["interval"], 2);
assert_eq!(rule["byMonthDay"][0], 15);
}
#[test]
fn absolute_yearly_translates_to_by_month_and_day() {
let raw = RawRecurrence {
pattern: Some(RecurrencePattern::AbsoluteYearly {
month: "January".to_owned(),
day_of_month: 1,
}),
range: Some(RecurrenceRange::NoEnd {
start_date: "2025-01-01".to_owned(),
}),
};
let rule = to_jscalendar_rule(&raw).unwrap();
assert_eq!(rule["frequency"], "yearly");
assert_eq!(rule["byMonth"][0], "1");
assert_eq!(rule["byMonthDay"][0], 1);
}
#[test]
fn no_end_range_emits_no_until_or_count() {
let raw = RawRecurrence {
pattern: Some(RecurrencePattern::Daily { interval: 1 }),
range: Some(RecurrenceRange::NoEnd {
start_date: "2025-01-01".to_owned(),
}),
};
let rule = to_jscalendar_rule(&raw).unwrap();
assert!(rule.get("until").is_none());
assert!(rule.get("count").is_none());
}
#[test]
fn weekly_two_days() {
let raw = RawRecurrence {
pattern: Some(RecurrencePattern::Weekly {
interval: 1,
days_of_week: vec!["Monday".to_owned(), "Wednesday".to_owned()],
}),
range: Some(RecurrenceRange::EndDate {
start_date: "2025-01-06".to_owned(),
end_date: "2025-06-30".to_owned(),
}),
};
let rule = to_jscalendar_rule(&raw).unwrap();
assert_eq!(rule["frequency"], "weekly");
assert_eq!(rule["until"], "2025-06-30T23:59:59");
let by_day = rule["byDay"].as_array().unwrap();
assert_eq!(by_day.len(), 2);
assert_eq!(by_day[0]["day"], "mo");
assert_eq!(by_day[1]["day"], "we");
}
}
+157
View File
@@ -0,0 +1,157 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::time::Duration;
use crate::exchange_ews::types::ResponseCode;
use crate::jmap::retry::Disposition;
pub fn classify_http_status(status: u16) -> Disposition {
match status {
429 | 502 | 503 | 504 => Disposition::Retryable,
_ => Disposition::Fatal,
}
}
#[derive(Debug, Clone)]
pub enum FaultDisposition {
Fatal,
Auth,
Retryable { delay: Option<Duration> },
}
pub fn classify_fault(code: &ResponseCode) -> FaultDisposition {
match code {
ResponseCode::ServerBusy { back_off_ms } => FaultDisposition::Retryable {
delay: back_off_ms.map(Duration::from_millis),
},
ResponseCode::TimeoutExpired
| ResponseCode::InternalServerTransientError
| ResponseCode::MailboxStoreUnavailable
| ResponseCode::ConnectionFailed
| ResponseCode::AdUnavailable
| ResponseCode::BatchProcessingStopped => FaultDisposition::Retryable { delay: None },
ResponseCode::AuthenticationRequired => FaultDisposition::Auth,
_ => FaultDisposition::Fatal,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemAction {
Success,
Vanished,
Skip,
Retryable,
RetryDiscardSyncState,
}
pub fn classify_item_response(code: &ResponseCode) -> ItemAction {
match code {
ResponseCode::NoError => ItemAction::Success,
ResponseCode::ItemNotFound | ResponseCode::AttachmentNotFound => ItemAction::Vanished,
ResponseCode::ServerBusy { .. }
| ResponseCode::TimeoutExpired
| ResponseCode::InternalServerTransientError
| ResponseCode::MailboxStoreUnavailable
| ResponseCode::ConnectionFailed
| ResponseCode::AdUnavailable
| ResponseCode::BatchProcessingStopped => ItemAction::Retryable,
ResponseCode::InvalidSyncStateData => ItemAction::RetryDiscardSyncState,
ResponseCode::AccessDenied
| ResponseCode::InvalidIdMalformed
| ResponseCode::MimeContentConversionFailed
| ResponseCode::ImpersonationFailed => ItemAction::Skip,
ResponseCode::AuthenticationRequired => ItemAction::Skip,
ResponseCode::Other(_) => ItemAction::Skip,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_busy_with_backoff_is_retryable_with_delay() {
let code = ResponseCode::ServerBusy {
back_off_ms: Some(297749),
};
match classify_fault(&code) {
FaultDisposition::Retryable { delay: Some(d) } => {
assert_eq!(d, Duration::from_millis(297749));
}
_ => panic!("expected retryable with delay"),
}
}
#[test]
fn item_responses_are_classified() {
assert_eq!(
classify_item_response(&ResponseCode::NoError),
ItemAction::Success
);
assert_eq!(
classify_item_response(&ResponseCode::ItemNotFound),
ItemAction::Vanished
);
assert_eq!(
classify_item_response(&ResponseCode::AttachmentNotFound),
ItemAction::Vanished
);
assert_eq!(
classify_item_response(&ResponseCode::AccessDenied),
ItemAction::Skip
);
assert_eq!(
classify_item_response(&ResponseCode::InvalidSyncStateData),
ItemAction::RetryDiscardSyncState
);
assert_eq!(
classify_item_response(&ResponseCode::ServerBusy { back_off_ms: None }),
ItemAction::Retryable
);
}
#[test]
fn http_status_table() {
assert_eq!(classify_http_status(429), Disposition::Retryable);
assert_eq!(classify_http_status(503), Disposition::Retryable);
assert_eq!(classify_http_status(500), Disposition::Fatal);
assert_eq!(classify_http_status(401), Disposition::Fatal);
}
#[test]
fn transient_codes_are_retryable_at_item_and_fault_level() {
for code in [
ResponseCode::InternalServerTransientError,
ResponseCode::MailboxStoreUnavailable,
ResponseCode::ConnectionFailed,
ResponseCode::AdUnavailable,
ResponseCode::BatchProcessingStopped,
] {
assert_eq!(classify_item_response(&code), ItemAction::Retryable);
assert!(matches!(
classify_fault(&code),
FaultDisposition::Retryable { .. }
));
}
}
#[test]
fn mime_content_conversion_failure_skips_item() {
assert_eq!(
classify_item_response(&ResponseCode::MimeContentConversionFailed),
ItemAction::Skip
);
}
#[test]
fn other_unknown_codes_skip_at_item_level() {
assert_eq!(
classify_item_response(&ResponseCode::Other("ErrorWhoKnows".to_owned())),
ItemAction::Skip
);
}
}
+117
View File
@@ -0,0 +1,117 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use crate::exchange_ews::types::ServerVersion;
pub const NS_SOAP: &str = "http://schemas.xmlsoap.org/soap/envelope/";
pub const NS_TYPES: &str = "http://schemas.microsoft.com/exchange/services/2006/types";
pub const NS_MESSAGES: &str = "http://schemas.microsoft.com/exchange/services/2006/messages";
pub const SOAP_ACTION_BASE: &str = "http://schemas.microsoft.com/exchange/services/2006/messages";
#[derive(Debug, Clone, Copy)]
pub struct EnvelopeOptions<'a> {
pub version: ServerVersion,
pub impersonated_smtp: Option<&'a str>,
}
impl Default for EnvelopeOptions<'_> {
fn default() -> Self {
EnvelopeOptions {
version: ServerVersion::Exchange2013Sp1,
impersonated_smtp: None,
}
}
}
pub fn wrap_envelope(opts: EnvelopeOptions<'_>, body: &str) -> String {
let mut out = String::with_capacity(body.len() + 512);
out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
out.push_str("<soap:Envelope xmlns:soap=\"");
out.push_str(NS_SOAP);
out.push_str("\" xmlns:t=\"");
out.push_str(NS_TYPES);
out.push_str("\" xmlns:m=\"");
out.push_str(NS_MESSAGES);
out.push_str("\"><soap:Header>");
out.push_str("<t:RequestServerVersion Version=\"");
out.push_str(opts.version.as_str());
out.push_str("\"/>");
if let Some(smtp) = opts.impersonated_smtp {
out.push_str("<t:ExchangeImpersonation><t:ConnectingSID><t:PrimarySmtpAddress>");
write_escaped(&mut out, smtp);
out.push_str("</t:PrimarySmtpAddress></t:ConnectingSID></t:ExchangeImpersonation>");
}
out.push_str("</soap:Header><soap:Body>");
out.push_str(body);
out.push_str("</soap:Body></soap:Envelope>");
out
}
pub fn soap_action(operation: &str) -> String {
format!("\"{SOAP_ACTION_BASE}/{operation}\"")
}
pub fn write_escaped(out: &mut String, s: &str) {
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(ch),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_has_canonical_namespaces() {
let env = wrap_envelope(EnvelopeOptions::default(), "<m:FindFolder/>");
assert!(env.contains("xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\""));
assert!(
env.contains("xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\"")
);
assert!(
env.contains(
"xmlns:m=\"http://schemas.microsoft.com/exchange/services/2006/messages\""
)
);
assert!(env.contains("<t:RequestServerVersion Version=\"Exchange2013_SP1\"/>"));
assert!(env.contains("<m:FindFolder/>"));
}
#[test]
fn envelope_impersonation_includes_smtp() {
let opts = EnvelopeOptions {
version: ServerVersion::Exchange2016,
impersonated_smtp: Some("[email protected]"),
};
let env = wrap_envelope(opts, "<m:FindFolder/>");
assert!(env.contains("<t:ExchangeImpersonation>"));
assert!(env.contains("<t:PrimarySmtpAddress>[email protected]</t:PrimarySmtpAddress>"));
assert!(env.contains("Version=\"Exchange2016\""));
}
#[test]
fn soap_action_quotes_operation() {
assert_eq!(
soap_action("FindItem"),
"\"http://schemas.microsoft.com/exchange/services/2006/messages/FindItem\""
);
}
#[test]
fn write_escaped_handles_metachars() {
let mut s = String::new();
write_escaped(&mut s, "<a&b>");
assert_eq!(s, "&lt;a&amp;b&gt;");
}
}
+314
View File
@@ -0,0 +1,314 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::fmt;
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct ItemId {
pub id: String,
pub change_key: String,
}
impl ItemId {
pub fn new(id: impl Into<String>, change_key: impl Into<String>) -> ItemId {
ItemId {
id: id.into(),
change_key: change_key.into(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct FolderId {
pub id: String,
pub change_key: String,
}
impl FolderId {
pub fn new(id: impl Into<String>, change_key: impl Into<String>) -> FolderId {
FolderId {
id: id.into(),
change_key: change_key.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MailboxKind {
Primary,
Archive,
PublicFolders,
}
impl MailboxKind {
pub fn as_str(self) -> &'static str {
match self {
MailboxKind::Primary => "primary",
MailboxKind::Archive => "archive",
MailboxKind::PublicFolders => "public-folders",
}
}
pub fn distinguished_root(self) -> DistinguishedFolderId {
match self {
MailboxKind::Primary => DistinguishedFolderId::MsgFolderRoot,
MailboxKind::Archive => DistinguishedFolderId::ArchiveRoot,
MailboxKind::PublicFolders => DistinguishedFolderId::PublicFoldersRoot,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DistinguishedFolderId {
MsgFolderRoot,
ArchiveRoot,
PublicFoldersRoot,
Inbox,
SentItems,
Drafts,
DeletedItems,
JunkEmail,
Outbox,
ConversationHistory,
Archive,
}
impl DistinguishedFolderId {
pub fn as_str(self) -> &'static str {
match self {
DistinguishedFolderId::MsgFolderRoot => "msgfolderroot",
DistinguishedFolderId::ArchiveRoot => "archiveroot",
DistinguishedFolderId::PublicFoldersRoot => "publicfoldersroot",
DistinguishedFolderId::Inbox => "inbox",
DistinguishedFolderId::SentItems => "sentitems",
DistinguishedFolderId::Drafts => "drafts",
DistinguishedFolderId::DeletedItems => "deleteditems",
DistinguishedFolderId::JunkEmail => "junkemail",
DistinguishedFolderId::Outbox => "outbox",
DistinguishedFolderId::ConversationHistory => "conversationhistory",
DistinguishedFolderId::Archive => "archive",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServerVersion {
Exchange2007,
Exchange2010,
Exchange2010Sp1,
Exchange2010Sp2,
Exchange2013,
Exchange2013Sp1,
Exchange2016,
Exchange2019,
}
impl ServerVersion {
pub fn as_str(self) -> &'static str {
match self {
ServerVersion::Exchange2007 => "Exchange2007",
ServerVersion::Exchange2010 => "Exchange2010",
ServerVersion::Exchange2010Sp1 => "Exchange2010_SP1",
ServerVersion::Exchange2010Sp2 => "Exchange2010_SP2",
ServerVersion::Exchange2013 => "Exchange2013",
ServerVersion::Exchange2013Sp1 => "Exchange2013_SP1",
ServerVersion::Exchange2016 => "Exchange2016",
ServerVersion::Exchange2019 => "Exchange2019",
}
}
pub fn from_build(major: u32, minor: u32) -> ServerVersion {
match (major, minor) {
(8, _) => ServerVersion::Exchange2007,
(14, 0) => ServerVersion::Exchange2010,
(14, 1) => ServerVersion::Exchange2010Sp1,
(14, 2 | 3) => ServerVersion::Exchange2010Sp2,
(15, 0) => ServerVersion::Exchange2013,
(15, 1) => ServerVersion::Exchange2016,
(15, 2) => ServerVersion::Exchange2019,
_ if major >= 15 => ServerVersion::Exchange2019,
_ => ServerVersion::Exchange2016,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FolderClass {
Mail,
Calendar,
Contacts,
Skipped,
}
impl FolderClass {
pub fn from_ipf(class: &str) -> FolderClass {
if class.eq_ignore_ascii_case("IPF.Note") {
FolderClass::Mail
} else if class.eq_ignore_ascii_case("IPF.Appointment")
|| class.starts_with("IPF.Appointment.")
{
FolderClass::Calendar
} else if class.eq_ignore_ascii_case("IPF.Contact") || class.starts_with("IPF.Contact.") {
FolderClass::Contacts
} else {
FolderClass::Skipped
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CalendarItemType {
Single,
RecurringMaster,
Occurrence,
Exception,
}
impl CalendarItemType {
pub fn parse(value: &str) -> Option<CalendarItemType> {
match value {
"Single" => Some(CalendarItemType::Single),
"RecurringMaster" => Some(CalendarItemType::RecurringMaster),
"Occurrence" => Some(CalendarItemType::Occurrence),
"Exception" => Some(CalendarItemType::Exception),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResponseCode {
NoError,
ItemNotFound,
AccessDenied,
InvalidIdMalformed,
ServerBusy { back_off_ms: Option<u64> },
TimeoutExpired,
InvalidSyncStateData,
MimeContentConversionFailed,
AttachmentNotFound,
AuthenticationRequired,
ImpersonationFailed,
BatchProcessingStopped,
InternalServerTransientError,
MailboxStoreUnavailable,
ConnectionFailed,
AdUnavailable,
Other(String),
}
impl ResponseCode {
pub fn parse(code: &str) -> ResponseCode {
match code {
"NoError" => ResponseCode::NoError,
"ErrorItemNotFound" => ResponseCode::ItemNotFound,
"ErrorAccessDenied" => ResponseCode::AccessDenied,
"ErrorInvalidIdMalformed" => ResponseCode::InvalidIdMalformed,
"ErrorServerBusy" => ResponseCode::ServerBusy { back_off_ms: None },
"ErrorTimeoutExpired" => ResponseCode::TimeoutExpired,
"ErrorInvalidSyncStateData" => ResponseCode::InvalidSyncStateData,
"ErrorMimeContentConversionFailed" => ResponseCode::MimeContentConversionFailed,
"ErrorAttachmentNotFound" => ResponseCode::AttachmentNotFound,
"ErrorAuthenticationRequired" => ResponseCode::AuthenticationRequired,
"ErrorImpersonationFailed" => ResponseCode::ImpersonationFailed,
"ErrorBatchProcessingStopped" => ResponseCode::BatchProcessingStopped,
"ErrorInternalServerTransientError" => ResponseCode::InternalServerTransientError,
"ErrorMailboxStoreUnavailable" => ResponseCode::MailboxStoreUnavailable,
"ErrorConnectionFailed" => ResponseCode::ConnectionFailed,
"ErrorADUnavailable" => ResponseCode::AdUnavailable,
other => ResponseCode::Other(other.to_owned()),
}
}
pub fn as_wire(&self) -> &str {
match self {
ResponseCode::NoError => "NoError",
ResponseCode::ItemNotFound => "ErrorItemNotFound",
ResponseCode::AccessDenied => "ErrorAccessDenied",
ResponseCode::InvalidIdMalformed => "ErrorInvalidIdMalformed",
ResponseCode::ServerBusy { .. } => "ErrorServerBusy",
ResponseCode::TimeoutExpired => "ErrorTimeoutExpired",
ResponseCode::InvalidSyncStateData => "ErrorInvalidSyncStateData",
ResponseCode::MimeContentConversionFailed => "ErrorMimeContentConversionFailed",
ResponseCode::AttachmentNotFound => "ErrorAttachmentNotFound",
ResponseCode::AuthenticationRequired => "ErrorAuthenticationRequired",
ResponseCode::ImpersonationFailed => "ErrorImpersonationFailed",
ResponseCode::BatchProcessingStopped => "ErrorBatchProcessingStopped",
ResponseCode::InternalServerTransientError => "ErrorInternalServerTransientError",
ResponseCode::MailboxStoreUnavailable => "ErrorMailboxStoreUnavailable",
ResponseCode::ConnectionFailed => "ErrorConnectionFailed",
ResponseCode::AdUnavailable => "ErrorADUnavailable",
ResponseCode::Other(s) => s.as_str(),
}
}
}
impl fmt::Display for ResponseCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_wire())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn folder_class_mapping() {
assert_eq!(FolderClass::from_ipf("IPF.Note"), FolderClass::Mail);
assert_eq!(
FolderClass::from_ipf("IPF.Appointment"),
FolderClass::Calendar
);
assert_eq!(
FolderClass::from_ipf("IPF.Appointment.Birthday"),
FolderClass::Calendar
);
assert_eq!(FolderClass::from_ipf("IPF.Contact"), FolderClass::Contacts);
assert_eq!(FolderClass::from_ipf("IPF.Task"), FolderClass::Skipped);
}
#[test]
fn server_version_from_build() {
assert_eq!(
ServerVersion::from_build(15, 0),
ServerVersion::Exchange2013
);
assert_eq!(
ServerVersion::from_build(15, 1),
ServerVersion::Exchange2016
);
assert_eq!(
ServerVersion::from_build(15, 2),
ServerVersion::Exchange2019
);
assert_eq!(
ServerVersion::from_build(99, 99),
ServerVersion::Exchange2019
);
}
#[test]
fn response_code_round_trip() {
let codes = [
"NoError",
"ErrorItemNotFound",
"ErrorAccessDenied",
"ErrorInvalidIdMalformed",
"ErrorServerBusy",
"ErrorTimeoutExpired",
"ErrorInvalidSyncStateData",
"ErrorMimeContentConversionFailed",
"ErrorAttachmentNotFound",
"ErrorAuthenticationRequired",
"ErrorImpersonationFailed",
"ErrorWhatever",
];
for c in codes {
assert_eq!(ResponseCode::parse(c).as_wire(), c);
}
}
}
+340
View File
@@ -0,0 +1,340 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use crate::exchange_ews::soap::write_escaped;
use crate::exchange_ews::types::{DistinguishedFolderId, FolderId, ItemId};
#[derive(Debug, Clone, Copy)]
pub enum FolderRef<'a> {
Distinguished(DistinguishedFolderId),
Concrete(&'a FolderId),
}
#[derive(Debug, Clone, Copy)]
pub enum FolderShape {
IdOnly,
}
impl FolderShape {
fn as_str(self) -> &'static str {
match self {
FolderShape::IdOnly => "IdOnly",
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum Traversal {
Shallow,
Deep,
}
impl Traversal {
fn as_str(self) -> &'static str {
match self {
Traversal::Shallow => "Shallow",
Traversal::Deep => "Deep",
}
}
}
pub fn find_folder_body(parent: FolderRef<'_>, traversal: Traversal) -> String {
let mut out = String::with_capacity(512);
out.push_str("<m:FindFolder Traversal=\"");
out.push_str(traversal.as_str());
out.push_str("\"><m:FolderShape>");
out.push_str("<t:BaseShape>Default</t:BaseShape>");
out.push_str("<t:AdditionalProperties>");
out.push_str("<t:FieldURI FieldURI=\"folder:FolderClass\"/>");
out.push_str("<t:FieldURI FieldURI=\"folder:DisplayName\"/>");
out.push_str("<t:FieldURI FieldURI=\"folder:ParentFolderId\"/>");
out.push_str("<t:FieldURI FieldURI=\"folder:TotalCount\"/>");
out.push_str("<t:FieldURI FieldURI=\"folder:ChildFolderCount\"/>");
out.push_str("</t:AdditionalProperties>");
out.push_str("</m:FolderShape><m:ParentFolderIds>");
write_folder_ref(&mut out, parent);
out.push_str("</m:ParentFolderIds></m:FindFolder>");
out
}
pub fn get_folder_body(folders: &[FolderRef<'_>], shape: FolderShape) -> String {
let mut out = String::with_capacity(256 + folders.len() * 64);
out.push_str("<m:GetFolder><m:FolderShape>");
out.push_str("<t:BaseShape>");
out.push_str(shape.as_str());
out.push_str("</t:BaseShape>");
out.push_str("</m:FolderShape><m:FolderIds>");
for f in folders {
write_folder_ref(&mut out, *f);
}
out.push_str("</m:FolderIds></m:GetFolder>");
out
}
pub fn find_item_body(
parent: FolderRef<'_>,
traversal: Traversal,
offset: u32,
page_size: u32,
) -> String {
let mut out = String::with_capacity(512);
out.push_str("<m:FindItem Traversal=\"");
out.push_str(traversal.as_str());
out.push_str("\"><m:ItemShape><t:BaseShape>IdOnly</t:BaseShape></m:ItemShape>");
out.push_str("<m:IndexedPageItemView MaxEntriesReturned=\"");
out.push_str(&page_size.to_string());
out.push_str("\" Offset=\"");
out.push_str(&offset.to_string());
out.push_str("\" BasePoint=\"Beginning\"/>");
out.push_str("<m:ParentFolderIds>");
write_folder_ref(&mut out, parent);
out.push_str("</m:ParentFolderIds></m:FindItem>");
out
}
pub fn sync_folder_items_body(folder: &FolderId, sync_state: &str, max_changes: u32) -> String {
let mut out = String::with_capacity(512);
out.push_str("<m:SyncFolderItems><m:ItemShape>");
out.push_str("<t:BaseShape>IdOnly</t:BaseShape>");
out.push_str("</m:ItemShape><m:SyncFolderId>");
write_folder_id_element(&mut out, folder);
out.push_str("</m:SyncFolderId>");
out.push_str("<m:SyncState>");
write_escaped(&mut out, sync_state);
out.push_str("</m:SyncState>");
out.push_str("<m:MaxChangesReturned>");
out.push_str(&max_changes.to_string());
out.push_str("</m:MaxChangesReturned>");
out.push_str("<m:SyncScope>NormalItems</m:SyncScope>");
out.push_str("</m:SyncFolderItems>");
out
}
#[derive(Debug, Clone, Copy)]
pub enum ItemShape {
Message,
CalendarItem,
Contact,
}
pub fn get_item_body(shape: ItemShape, ids: &[ItemId]) -> String {
let mut out = String::with_capacity(256 + ids.len() * 128);
out.push_str("<m:GetItem><m:ItemShape>");
match shape {
ItemShape::Message => {
out.push_str("<t:BaseShape>IdOnly</t:BaseShape>");
out.push_str("<t:IncludeMimeContent>true</t:IncludeMimeContent>");
out.push_str("<t:BodyType>Best</t:BodyType>");
out.push_str("<t:AdditionalProperties>");
out.push_str("<t:FieldURI FieldURI=\"item:DateTimeReceived\"/>");
out.push_str("<t:FieldURI FieldURI=\"item:IsRead\"/>");
out.push_str("<t:FieldURI FieldURI=\"item:IsDraft\"/>");
out.push_str("<t:FieldURI FieldURI=\"item:Categories\"/>");
out.push_str("<t:FieldURI FieldURI=\"item:ParentFolderId\"/>");
out.push_str("<t:FieldURI FieldURI=\"item:Flag\"/>");
out.push_str("<t:FieldURI FieldURI=\"message:IsReadReceiptRequested\"/>");
out.push_str("</t:AdditionalProperties>");
}
ItemShape::CalendarItem => {
out.push_str("<t:BaseShape>IdOnly</t:BaseShape>");
out.push_str("<t:BodyType>Best</t:BodyType>");
out.push_str("<t:AdditionalProperties>");
for f in CALENDAR_FIELDS {
out.push_str("<t:FieldURI FieldURI=\"");
out.push_str(f);
out.push_str("\"/>");
}
out.push_str("</t:AdditionalProperties>");
}
ItemShape::Contact => {
out.push_str("<t:BaseShape>AllProperties</t:BaseShape>");
}
}
out.push_str("</m:ItemShape><m:ItemIds>");
for id in ids {
write_item_id_element(&mut out, id);
}
out.push_str("</m:ItemIds></m:GetItem>");
out
}
const CALENDAR_FIELDS: &[&str] = &[
"calendar:UID",
"calendar:RecurrenceId",
"calendar:Start",
"calendar:End",
"calendar:OriginalStart",
"calendar:IsAllDayEvent",
"calendar:LegacyFreeBusyStatus",
"calendar:Location",
"calendar:IsRecurring",
"calendar:CalendarItemType",
"calendar:Organizer",
"calendar:RequiredAttendees",
"calendar:OptionalAttendees",
"calendar:Recurrence",
"calendar:ModifiedOccurrences",
"calendar:DeletedOccurrences",
"calendar:StartTimeZone",
"calendar:EndTimeZone",
"calendar:Duration",
"item:Subject",
"item:Body",
"item:Categories",
"item:DateTimeCreated",
"item:LastModifiedTime",
"item:ParentFolderId",
];
pub fn get_attachment_body(ids: &[&str]) -> String {
let mut out = String::with_capacity(256 + ids.len() * 64);
out.push_str("<m:GetAttachment><m:AttachmentShape>");
out.push_str("<t:IncludeMimeContent>true</t:IncludeMimeContent>");
out.push_str("<t:BodyType>Best</t:BodyType>");
out.push_str("</m:AttachmentShape><m:AttachmentIds>");
for id in ids {
out.push_str("<t:AttachmentId Id=\"");
write_escaped(&mut out, id);
out.push_str("\"/>");
}
out.push_str("</m:AttachmentIds></m:GetAttachment>");
out
}
pub fn write_folder_id_element(out: &mut String, folder: &FolderId) {
out.push_str("<t:FolderId Id=\"");
write_escaped(out, &folder.id);
out.push('"');
if !folder.change_key.is_empty() {
out.push_str(" ChangeKey=\"");
write_escaped(out, &folder.change_key);
out.push('"');
}
out.push_str("/>");
}
pub fn write_item_id_element(out: &mut String, id: &ItemId) {
out.push_str("<t:ItemId Id=\"");
write_escaped(out, &id.id);
out.push('"');
if !id.change_key.is_empty() {
out.push_str(" ChangeKey=\"");
write_escaped(out, &id.change_key);
out.push('"');
}
out.push_str("/>");
}
fn write_folder_ref(out: &mut String, parent: FolderRef<'_>) {
match parent {
FolderRef::Distinguished(d) => {
out.push_str("<t:DistinguishedFolderId Id=\"");
out.push_str(d.as_str());
out.push_str("\"/>");
}
FolderRef::Concrete(f) => write_folder_id_element(out, f),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_folder_uses_deep_traversal_for_msgfolderroot() {
let body = find_folder_body(
FolderRef::Distinguished(DistinguishedFolderId::MsgFolderRoot),
Traversal::Deep,
);
assert!(body.contains("Traversal=\"Deep\""));
assert!(body.contains("<t:DistinguishedFolderId Id=\"msgfolderroot\"/>"));
assert!(body.contains("folder:FolderClass"));
assert!(body.contains("folder:DisplayName"));
assert!(body.contains("folder:ParentFolderId"));
}
#[test]
fn find_folder_archive_uses_archiveroot() {
let body = find_folder_body(
FolderRef::Distinguished(DistinguishedFolderId::ArchiveRoot),
Traversal::Deep,
);
assert!(body.contains("<t:DistinguishedFolderId Id=\"archiveroot\"/>"));
}
#[test]
fn find_item_paginates_with_offset_and_page_size() {
let folder = FolderId::new("FID", "FCK");
let body = find_item_body(FolderRef::Concrete(&folder), Traversal::Shallow, 200, 50);
assert!(body.contains("Offset=\"200\""));
assert!(body.contains("MaxEntriesReturned=\"50\""));
assert!(body.contains("<t:FolderId Id=\"FID\" ChangeKey=\"FCK\"/>"));
}
#[test]
fn get_item_message_shape_requests_mime() {
let ids = vec![ItemId::new("I1", "CK1"), ItemId::new("I2", "")];
let body = get_item_body(ItemShape::Message, &ids);
assert!(body.contains("<t:IncludeMimeContent>true</t:IncludeMimeContent>"));
assert!(body.contains("<t:ItemId Id=\"I1\" ChangeKey=\"CK1\"/>"));
assert!(body.contains("<t:ItemId Id=\"I2\"/>"));
}
#[test]
fn get_item_calendar_shape_lists_calendar_fields() {
let body = get_item_body(ItemShape::CalendarItem, &[ItemId::new("X", "")]);
assert!(body.contains("calendar:Recurrence"));
assert!(body.contains("calendar:ModifiedOccurrences"));
assert!(body.contains("calendar:DeletedOccurrences"));
assert!(body.contains("calendar:UID"));
assert!(!body.contains("<t:IncludeMimeContent>"));
}
#[test]
fn get_item_contact_shape_requests_all_properties() {
let body = get_item_body(ItemShape::Contact, &[ItemId::new("X", "")]);
assert!(body.contains("<t:BaseShape>AllProperties</t:BaseShape>"));
}
#[test]
fn sync_folder_items_body_carries_state_and_max() {
let folder = FolderId::new("FID", "FCK");
let body = sync_folder_items_body(&folder, "STATE", 512);
assert!(body.contains("<m:SyncFolderId><t:FolderId Id=\"FID\" ChangeKey=\"FCK\"/>"));
assert!(body.contains("<m:SyncState>STATE</m:SyncState>"));
assert!(body.contains("<m:MaxChangesReturned>512</m:MaxChangesReturned>"));
assert!(body.contains("<m:SyncScope>NormalItems</m:SyncScope>"));
}
#[test]
fn sync_folder_items_empty_state_round_trips() {
let folder = FolderId::new("FID", "");
let body = sync_folder_items_body(&folder, "", 100);
assert!(body.contains("<m:SyncState></m:SyncState>"));
}
#[test]
fn get_attachment_lists_ids() {
let body = get_attachment_body(&["AID1", "AID2"]);
assert!(body.contains("<t:AttachmentId Id=\"AID1\"/>"));
assert!(body.contains("<t:AttachmentId Id=\"AID2\"/>"));
assert!(body.contains("<t:IncludeMimeContent>true</t:IncludeMimeContent>"));
}
#[test]
fn get_folder_emits_distinguished_ids() {
let body = get_folder_body(
&[
FolderRef::Distinguished(DistinguishedFolderId::Inbox),
FolderRef::Distinguished(DistinguishedFolderId::SentItems),
],
FolderShape::IdOnly,
);
assert!(body.contains("<t:DistinguishedFolderId Id=\"inbox\"/>"));
assert!(body.contains("<t:DistinguishedFolderId Id=\"sentitems\"/>"));
assert!(body.contains("<t:BaseShape>IdOnly</t:BaseShape>"));
}
}
+286
View File
@@ -0,0 +1,286 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use serde_json::Value;
use crate::exchange_graph::client::GraphClient;
use crate::exchange_graph::error::GraphError;
use crate::exchange_graph::types::MailboxKind;
pub const DEFAULT_API_BASE: &str = "https://graph.microsoft.com/v1.0";
pub const PREFER_TIMEZONE_UTC: &str = "outlook.timezone=\"UTC\"";
pub const PREFER_BODY_TEXT: &str = "outlook.body-content-type=\"text\"";
pub const PREFER_BODY_HTML: &str = "outlook.body-content-type=\"html\"";
#[derive(Debug, Clone)]
pub struct Endpoints {
pub api_base: String,
pub user_path: String,
}
impl Endpoints {
pub fn for_me(api_base: &str) -> Endpoints {
Endpoints {
api_base: api_base.trim_end_matches('/').to_owned(),
user_path: "/me".to_owned(),
}
}
pub fn for_user(api_base: &str, user_id: &str) -> Endpoints {
Endpoints {
api_base: api_base.trim_end_matches('/').to_owned(),
user_path: format!("/users/{user_id}"),
}
}
pub fn me_or_user(&self) -> String {
format!("{}{}", self.api_base, self.user_path)
}
pub fn mail_folders_root(&self, kind: MailboxKind, top: usize) -> String {
match kind {
MailboxKind::Primary => {
format!(
"{}/mailFolders?$top={top}&includeHiddenFolders=true",
self.me_or_user()
)
}
MailboxKind::Archive => {
format!(
"{}/mailFolders/archive/childFolders?$top={top}&includeHiddenFolders=true",
self.me_or_user()
)
}
}
}
pub fn mail_folder_child_folders(&self, folder_id: &str, top: usize) -> String {
format!(
"{}/mailFolders/{}/childFolders?$top={top}&includeHiddenFolders=true",
self.me_or_user(),
url_escape(folder_id)
)
}
pub fn folder_messages_ids(&self, folder_id: &str, top: usize) -> String {
format!(
"{}/mailFolders/{}/messages?$top={top}&$select=id",
self.me_or_user(),
url_escape(folder_id)
)
}
pub fn message_mime(&self, message_id: &str) -> String {
format!(
"{}/messages/{}/$value",
self.me_or_user(),
url_escape(message_id)
)
}
pub fn well_known_folder(&self, well_known: &str) -> String {
format!(
"{}/mailFolders/{}?$select=id",
self.me_or_user(),
well_known
)
}
pub fn calendars(&self, top: usize) -> String {
format!("{}/calendars?$top={top}", self.me_or_user())
}
pub fn calendar_events_ids(&self, calendar_id: &str, top: usize) -> String {
format!(
"{}/calendars/{}/events?$top={top}&$select=id,type,seriesMasterId",
self.me_or_user(),
url_escape(calendar_id)
)
}
pub fn event(&self, event_id: &str) -> String {
format!("{}/events/{}", self.me_or_user(), url_escape(event_id))
}
pub fn contact_folders(&self, top: usize) -> String {
format!("{}/contactFolders?$top={top}", self.me_or_user())
}
pub fn contact_folder_children(&self, folder_id: &str, top: usize) -> String {
format!(
"{}/contactFolders/{}/childFolders?$top={top}",
self.me_or_user(),
url_escape(folder_id)
)
}
pub fn contact_folder_contacts_ids(&self, folder_id: &str, top: usize) -> String {
format!(
"{}/contactFolders/{}/contacts?$top={top}&$select=id",
self.me_or_user(),
url_escape(folder_id)
)
}
pub fn contact(&self, contact_id: &str) -> String {
format!("{}/contacts/{}", self.me_or_user(), url_escape(contact_id))
}
pub fn mailbox_settings_timezone(&self) -> String {
format!("{}/mailboxSettings?$select=timeZone", self.me_or_user())
}
pub fn me_select_id_upn(&self) -> String {
format!(
"{}?$select=id,userPrincipalName,displayName,mail",
self.me_or_user()
)
}
}
fn url_escape(segment: &str) -> String {
let mut out = String::with_capacity(segment.len());
for b in segment.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
use std::fmt::Write;
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
pub fn paged_collect<F>(
client: &GraphClient,
initial_url: &str,
prefer: &[&str],
mut on_page: F,
) -> Result<(), GraphError>
where
F: FnMut(&Value) -> Result<(), GraphError>,
{
let mut url = initial_url.to_owned();
loop {
let body = client.get_json_with_prefer(&url, prefer)?;
on_page(&body)?;
match body.get("@odata.nextLink").and_then(Value::as_str) {
Some(next) => url = next.to_owned(),
None => return Ok(()),
}
}
}
pub fn collect_all_ids(
client: &GraphClient,
initial_url: &str,
prefer: &[&str],
) -> Result<Vec<String>, GraphError> {
let mut out = Vec::new();
paged_collect(client, initial_url, prefer, |page| {
if let Some(arr) = page.get("value").and_then(Value::as_array) {
for item in arr {
if let Some(id) = item.get("id").and_then(Value::as_str) {
out.push(id.to_owned());
}
}
}
Ok(())
})?;
Ok(out)
}
pub fn collect_all_values(
client: &GraphClient,
initial_url: &str,
prefer: &[&str],
) -> Result<Vec<Value>, GraphError> {
let mut out = Vec::new();
paged_collect(client, initial_url, prefer, |page| {
if let Some(arr) = page.get("value").and_then(Value::as_array) {
for item in arr {
out.push(item.clone());
}
}
Ok(())
})?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn primary_mail_folders_root_uses_me_with_hidden() {
let e = Endpoints::for_me(DEFAULT_API_BASE);
let url = e.mail_folders_root(MailboxKind::Primary, 100);
assert!(url.starts_with("https://graph.microsoft.com/v1.0/me/mailFolders?"));
assert!(url.contains("includeHiddenFolders=true"));
assert!(url.contains("$top=100"));
}
#[test]
fn archive_mail_folders_root_uses_archive_child_folders() {
let e = Endpoints::for_me(DEFAULT_API_BASE);
let url = e.mail_folders_root(MailboxKind::Archive, 50);
assert!(
url.starts_with(
"https://graph.microsoft.com/v1.0/me/mailFolders/archive/childFolders?"
)
);
assert!(url.contains("$top=50"));
}
#[test]
fn for_user_replaces_me_segment() {
let e = Endpoints::for_user(DEFAULT_API_BASE, "uid");
let url = e.mail_folders_root(MailboxKind::Primary, 100);
assert!(url.contains("/users/uid/mailFolders"));
assert!(!url.contains("/me/"));
}
#[test]
fn top_threads_through_calendar_and_contact_endpoints() {
let e = Endpoints::for_me(DEFAULT_API_BASE);
assert!(e.calendars(75).contains("$top=75"));
assert!(e.contact_folders(75).contains("$top=75"));
assert!(e.contact_folder_children("F", 75).contains("$top=75"));
assert!(e.mail_folder_child_folders("F", 75).contains("$top=75"));
}
#[test]
fn url_escape_encodes_unsafe_bytes_only() {
assert_eq!(url_escape("AAkAAA="), "AAkAAA%3D");
assert_eq!(url_escape("plain.id_123-x"), "plain.id_123-x");
}
#[test]
fn message_mime_endpoint_uses_value() {
let e = Endpoints::for_me(DEFAULT_API_BASE);
let url = e.message_mime("AAkA==");
assert!(url.ends_with("/messages/AAkA%3D%3D/$value"));
}
#[test]
fn well_known_folder_uses_short_name() {
let e = Endpoints::for_me(DEFAULT_API_BASE);
let url = e.well_known_folder("inbox");
assert!(url.ends_with("/mailFolders/inbox?$select=id"));
}
#[test]
fn event_ids_endpoint_requests_routing_fields() {
let e = Endpoints::for_me(DEFAULT_API_BASE);
let url = e.calendar_events_ids("CAL", 100);
assert!(url.contains("$select=id,type,seriesMasterId"));
assert!(url.contains("$top=100"));
}
}

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