Initial commit
This commit is contained in:
@@ -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
@@ -0,0 +1,10 @@
|
||||
debug
|
||||
target
|
||||
**/*.rs.bk
|
||||
**/mutants.out*/
|
||||
.env
|
||||
*.sqlite
|
||||
*.md
|
||||
!README.md
|
||||
!CHANGELOG.md
|
||||
specs/
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+53
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<a href="https://docs.rs/vandelay"><img src="https://img.shields.io/docsrs/vandelay?style=flat-square" alt="docs.rs"></a>
|
||||
|
||||
<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">
|
||||
|
||||
|
||||
## 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 |
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
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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
|
||||
@@ -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
|
||||
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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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
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
|
||||
/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
|
||||
@@ -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
|
||||
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
|
||||
END:VCARD
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:John Doe
|
||||
UID:urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af
|
||||
END:VCARD
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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(¤t_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(¤t_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: ¤t_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(¤t_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: ¤t_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(¤t_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 {
|
||||
¤t_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()));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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/");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => 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/<weird>.ics".to_owned());
|
||||
let body = calendar_multiget(&[h]);
|
||||
assert!(body.contains("&lt;weird&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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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(_, _)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod tz;
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, ¤t_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, ¤t_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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => 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, "<a&b>");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>"));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user